diff --git a/server/.gitattributes b/server/.gitattributes new file mode 100644 index 0000000..1fbf887 --- /dev/null +++ b/server/.gitattributes @@ -0,0 +1 @@ +* linguist-language=GO \ No newline at end of file diff --git a/server/README.MD b/server/README.MD new file mode 100644 index 0000000..32a29cd --- /dev/null +++ b/server/README.MD @@ -0,0 +1,278 @@ +# StackChan Server + +**StackChan Server** is the backend server for the open-source StackChan project. It provides RESTful APIs for device management, user authentication, post management, comment systems, and dance control functionalities. + +--- + +## Features + +- **Device Management**: Device binding, unbinding, and information updates +- **User Authentication**: Login, registration, and JWT-based token authentication +- **Post System**: Create, read, and manage posts with text and image support +- **Comment System**: Full CRUD operations for post comments +- **Dance Control**: Dance creation, management, and motion data handling +- **App Store Integration**: App management and distribution +- **AI Agent Integration**: XiaoZhi (小智) AI assistant integration with agent configuration +- **Persistent Storage**: MySQL database with ORM-based data access + +--- + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Database Setup](#database-setup) +- [Configuration](#configuration) +- [Installation](#installation) +- [Running the Server](#running-the-server) +- [API Documentation](#api-documentation) +- [Project Structure](#project-structure) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Prerequisites + +- **Go**: The project is developed in Go. Install **Go 1.24+** from + the [official download page](https://golang.google.cn/dl/). + +Verify installation: + +```bash +go version +# Expected output: "go version go1.24.x ..." (or similar) +``` + +- **MySQL**: Version 8.0+ for database storage +- **Git**: For cloning the repository + +--- + +## Database Setup + +Before running the project, you need to create the MySQL database first: + +```bash +# Execute the database initialization script +mysql -u your_username -p < check_list/create_mysql_database.sql +``` + +This will create the `stackChan` database and all required tables. + +--- + +## Configuration + +Update the `manifest/config/config.yaml` file with your actual configuration: + +### 1. Database Connection + +```yaml +database: + default: + link: "mysql:your_username:your_password@tcp(127.0.0.1:3306)/stackChan?charset=utf8mb4&collation=utf8mb4_0900_ai_ci" +``` + +### 2. JWT Configuration + +For production use, generate your own secure JWT secret key. + +**Generate a secure JWT secret key:** + +Option 1: Using OpenSSL (recommended) +```bash +# Generate a 32-byte (256-bit) random secret key encoded in base64 +openssl rand -base64 32 +``` + +Option 2: Using Python +```bash +python3 -c "import secrets; import base64; print(base64.b64encode(secrets.token_bytes(32)).decode())" +``` + +Update the configuration in `manifest/config/config.yaml`: +```yaml +jwt: + secret: "your_generated_secret_key_here" +``` + +### 3. RSA Keys + +For production use, generate your own RSA key pairs for encryption. + +### 4. XiaoZhi Configuration + +Set your XiaoZhi (小智) API secret key: + +```yaml +xiaozhi: + secret_key: "your_xiaozhi_secret_key_here" + generate_license_token: "your_xiaozhi_generate_license_token_here" +``` + +--- + +## Installation + +```bash +# Clone the repository +git clone https://github.com/m5stack/StackChan.git +cd StackChan/server + +# Download dependencies +go mod download +``` + +--- + +## Running the Server + +### Development Mode + +```bash +go run main.go +``` + +### Build and Run + +```bash +# Build for current platform +go build -o stackchan-server main.go + +# Run +./stackchan-server # Linux/macOS +stackchan-server.exe # Windows +``` + +### Using Makefile + +```bash +# Build +make build + +# Run +make run +``` + +--- + +## API Documentation + +The server provides RESTful APIs organized by module: + +### Device APIs (`/api/device/*`) +- `POST /device/bind` - Bind a device to the current user +- `POST /device/unbind` - Unbind a device from the current user +- `PUT /device/update` - Update device information +- `GET /devices` - Get list of devices + +### User APIs (`/api/user/*`) +- `POST /user/login` - User login +- `POST /user/registration` - User registration +- `GET /user` - Get user information + +### Dance APIs (`/api/dance/*`) +- `POST /dance` - Create a new dance +- `GET /dance` - Get dance information +- `PUT /dance` - Update dance details +- `DELETE /dance` - Delete a dance + +### Post APIs (`/api/post/*`) +- `GET /post/get` - Get post details +- Post listing and comment operations + +### Admin APIs (`/api/admin/*`) +- App management operations +- User management + +--- + +## Project Structure + +``` +stackChan/ +├── api/ # API definitions and request/response structures +│ ├── admin/ # Admin module APIs +│ ├── appstore/ # App store module APIs +│ ├── dance/ # Dance module APIs +│ ├── device/ # Device module APIs +│ ├── friend/ # Friend module APIs +│ ├── post/ # Post module APIs +│ ├── user/ # User module APIs +│ └── xiaozhi/ # XiaoZhi AI module APIs +├── internal/ # Internal application code +│ ├── boot/ # Boot initialization +│ ├── cmd/ # Command entry +│ ├── consts/ # Constants definitions +│ ├── controller/ # API controllers +│ ├── dao/ # Data Access Objects +│ ├── logic/ # Business logic +│ ├── middleware/ # HTTP middlewares +│ ├── model/ # Data models +│ ├── packed/ # Packed assets +│ ├── service/ # Business services +│ └── xiaozhi/ # XiaoZhi integration +├── manifest/ # Deployment and configuration +│ ├── config/ # Configuration files +│ ├── deploy/ # Deployment scripts +│ └── docker/ # Docker configurations +├── utility/ # Utility functions (RSA, etc.) +├── web/ # Web frontend assets +├── main.go # Application entry +├── go.mod # Go module definition +├── go.sum # Go dependencies checksum +├── Makefile # Build scripts +└── README.MD # This file +``` + +--- + +## Architecture Overview + +The project follows a layered architecture: + +1. **API Layer**: Defines request/response structures and routes +2. **Controller Layer**: Handles HTTP requests and responses +3. **Service Layer**: Implements business logic +4. **DAO Layer**: Data access and database operations +5. **Model Layer**: Data structures and entities + +The project uses the GoFrame framework for rapid development and robust infrastructure. + +--- + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +For major changes, please open an issue first to discuss what you would like to change. + +### Development Guidelines + +- Follow Go conventions and best practices +- Write clear, descriptive comments in English +- Add tests for new features +- Ensure all existing tests pass +- Update documentation as needed + +--- + +## License + +[SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD](LICENSE) + +[SPDX-License-Identifier: MIT](LICENSE) + +--- + +## Support + +For questions and support, please open an issue in the GitHub repository or contact the M5Stack team. + +--- + +## Acknowledgments + +- [GoFrame](https://goframe.org/) - The Go framework used in this project +- [M5Stack](https://m5stack.com/) - For supporting open-source development +- All contributors who help improve this project \ No newline at end of file diff --git a/server/README.md b/server/README.md deleted file mode 100644 index 3d43d8c..0000000 --- a/server/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# StackChan Server - -**StackChan Server** is the Server of the open-source StackChan project. It handles core functionalities such -as device interactions, post management, and comment systems, providing stable and efficient API support. - ---- - -## Features - -- App and StackChan communication and interaction -- Device post creation and management (text and image support, similar to a social feed) -- Comment CRUD (Create, Read, Update, Delete) operations -- Dance control and data management -- Persistent storage using a relational database - ---- - -## Getting Started - -### Prerequisites - -- **Go**: The project is developed in Go. Install **Go 1.24+** from - the [official download page](https://golang.google.cn/dl/). - -Verify installation: - -```bash -go version -# Expected output: "go version go1.24.x ..." (or similar) - -### Clone the Repository -```bash -git clone https://github.com/m5stack/StackChan # Replace with the actual repository URL -cd StackChan/server - -# Download dependencies -go mod download - -# build -go build -o StackChan main.go - -# Start running -StackChan # Linux/macOS -StackChan.exe # Windows diff --git a/server/api/admin/admin.go b/server/api/admin/admin.go new file mode 100644 index 0000000..5dc8530 --- /dev/null +++ b/server/api/admin/admin.go @@ -0,0 +1,19 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package admin + +import ( + "context" + + "stackChan/api/admin/v1" +) + +type IAdminV1 interface { + AdminLogin(ctx context.Context, req *v1.AdminLoginReq) (res *v1.AdminLoginRes, err error) + AddApp(ctx context.Context, req *v1.AddAppReq) (res *v1.AddAppRes, err error) + GetAppList(ctx context.Context, req *v1.GetAppListReq) (res *v1.GetAppListRes, err error) + DeleteApp(ctx context.Context, req *v1.DeleteAppReq) (res *v1.DeleteAppRes, err error) + UpdateApp(ctx context.Context, req *v1.UpdateAppReq) (res *v1.UpdateAppRes, err error) +} diff --git a/server/api/admin/v1/admin_user.go b/server/api/admin/v1/admin_user.go new file mode 100644 index 0000000..797ae01 --- /dev/null +++ b/server/api/admin/v1/admin_user.go @@ -0,0 +1,18 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +type AdminLoginReq struct { + g.Meta `path:"/login" method:"post" tags:"Info" summary:"admin login info"` + UserName string `json:"user_name" description:"Admin username"` + Password string `json:"pass_word" description:"Admin password"` +} + +type AdminLoginRes struct { + Token string `json:"token"` +} diff --git a/server/api/admin/v1/app_store.go b/server/api/admin/v1/app_store.go new file mode 100644 index 0000000..081d1b6 --- /dev/null +++ b/server/api/admin/v1/app_store.go @@ -0,0 +1,46 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package v1 + +import ( + "stackChan/internal/model" + + "github.com/gogf/gf/v2/frame/g" +) + +type AddAppReq struct { + g.Meta `path:"/app/add" method:"post" tags:"App" summary:"App add request"` + AppName string `json:"appName" orm:"app_name" v:"required" d:"" description:"App name (required)"` + AppIconUrl string `json:"appIconUrl" orm:"app_icon_url" d:"" description:"App icon URL (optional)"` + Description string `json:"description" orm:"description" d:"" description:"App description (optional)"` + FirmwareUrl string `json:"firmwareUrl" orm:"firmware_url" d:"" description:"Firmware / installation package download URL (optional)"` +} + +type AddAppRes model.AppInfo + +type GetAppListReq struct { + g.Meta `path:"/apps" method:"get" tags:"App" summary:"App List Get"` +} + +type GetAppListRes []model.AppInfo + +type DeleteAppReq struct { + g.Meta `path:"/app/delete" method:"delete" tags:"App" summary:"App delete"` + Id int64 `json:"id" orm:"id" description:"App ID"` // App ID +} + +type DeleteAppRes struct{} + +type UpdateAppReq struct { + g.Meta `path:"/app/update" method:"put" tags:"App" summary:"App put"` + Id int64 `json:"id" orm:"id" v:"required" description:"App ID (required)"` + AppName string `json:"appName" orm:"app_name" d:"" description:"App name (optional)"` + AppIconUrl string `json:"appIconUrl" orm:"app_icon_url" d:"" description:"App icon URL (optional)"` + Description string `json:"description" orm:"description" d:"" description:"App description (optional)"` + FirmwareUrl string `json:"firmwareUrl" orm:"firmware_url" d:"" description:"Firmware / installation package download URL (optional)"` +} + +type UpdateAppRes model.AppInfo diff --git a/server/internal/model/entity/sqlite_sequence.go b/server/api/appstore/appstore.go similarity index 51% rename from server/internal/model/entity/sqlite_sequence.go rename to server/api/appstore/appstore.go index dd1d7a0..75f9efa 100644 --- a/server/internal/model/entity/sqlite_sequence.go +++ b/server/api/appstore/appstore.go @@ -2,10 +2,14 @@ // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= -package entity +package appstore -// SqliteSequence is the golang structure for table sqlite_sequence. -type SqliteSequence struct { - Name string `json:"name" orm:"name" description:""` // - Seq string `json:"seq" orm:"seq" description:""` // +import ( + "context" + + "stackChan/api/appstore/v1" +) + +type IAppstoreV1 interface { + GetAppList(ctx context.Context, req *v1.GetAppListReq) (res *v1.GetAppListRes, err error) } diff --git a/server/api/appstore/v1/app_store.go b/server/api/appstore/v1/app_store.go new file mode 100644 index 0000000..fdf03bf --- /dev/null +++ b/server/api/appstore/v1/app_store.go @@ -0,0 +1,18 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package v1 + +import ( + "stackChan/internal/model" + + "github.com/gogf/gf/v2/frame/g" +) + +type GetAppListReq struct { + g.Meta `path:"/apps" method:"get" tags:"App" summary:"App List Get"` +} + +type GetAppListRes []model.AppInfo diff --git a/server/api/dance/dance.go b/server/api/dance/dance.go index aa3d948..1c398f0 100644 --- a/server/api/dance/dance.go +++ b/server/api/dance/dance.go @@ -8,6 +8,7 @@ import ( "context" "stackChan/api/dance/v1" + "stackChan/api/dance/v2" ) type IDanceV1 interface { @@ -15,4 +16,14 @@ type IDanceV1 interface { Delete(ctx context.Context, req *v1.DeleteReq) (res *v1.DeleteRes, err error) Update(ctx context.Context, req *v1.UpdateReq) (res *v1.UpdateRes, err error) GetList(ctx context.Context, req *v1.GetListReq) (res *v1.GetListRes, err error) + GetDanceInfo(ctx context.Context, req *v1.GetDanceInfoReq) (res *v1.GetDanceInfoRes, err error) + GetMusicList(ctx context.Context, req *v1.GetMusicListReq) (res *v1.GetMusicListRes, err error) +} + +type IDanceV2 interface { + GetList(ctx context.Context, req *v2.GetListReq) (res *v2.GetListRes, err error) + Create(ctx context.Context, req *v2.CreateReq) (res *v2.CreateRes, err error) + Delete(ctx context.Context, req *v2.DeleteReq) (res *v2.DeleteRes, err error) + Update(ctx context.Context, req *v2.UpdateReq) (res *v2.UpdateRes, err error) + GetDanceInfo(ctx context.Context, req *v2.GetDanceInfoReq) (res *v2.GetDanceInfoRes, err error) } diff --git a/server/api/dance/v1/dance.go b/server/api/dance/v1/dance.go index a592392..0bf4277 100644 --- a/server/api/dance/v1/dance.go +++ b/server/api/dance/v1/dance.go @@ -6,40 +6,53 @@ SPDX-License-Identifier: MIT package v1 import ( + "encoding/json" "stackChan/internal/model" "github.com/gogf/gf/v2/frame/g" ) type CreateReq struct { - g.Meta `path:"/dance" method:"post" tags:"Dance" summary:"Dance create request"` - Mac string `json:"mac" v:"required"` - Index int `json:"index" v:"required"` - List []model.DanceData `json:"list" v:"required"` + g.Meta `path:"/dance" method:"post" tags:"Dance" summary:"Dance create request"` + DanceData json.RawMessage `json:"danceData"` // Dance motion data + DanceName string `json:"danceName" v:"required"` // Dance name + MusicUrl string `json:"musicUrl"` // Dance background music URL } type CreateRes string type DeleteReq struct { g.Meta `path:"/dance" method:"delete" tags:"Dance" summary:"Dance delete request"` - Mac string `json:"mac" v:"required"` - Index int `json:"index" v:"required"` + Id int64 `json:"id" v:"required"` } type DeleteRes string type UpdateReq struct { - g.Meta `path:"/dance" method:"put" tags:"Dance" summary:"Dance put request"` - Mac string `json:"mac" v:"required"` - Index int `json:"index" v:"required"` - Data []model.DanceData `json:"list" v:"required"` + g.Meta `path:"/dance" method:"put" tags:"Dance" summary:"Dance put request"` + Id int64 `json:"id" v:"required"` + DanceData json.RawMessage `json:"danceData"` // Dance motion data + DanceName string `json:"danceName"` // Dance name + MusicUrl string `json:"musicUrl"` // Dance background music URL } type UpdateRes string type GetListReq struct { g.Meta `path:"/dance" method:"get" tags:"Dance" summary:"Dance get request"` - Mac string `json:"mac" v:"required"` } -type GetListRes map[string][]model.DanceData +type GetListRes []model.Dance + +type GetDanceInfoReq struct { + g.Meta `path:"/danceData" method:"get" tags:"Dance get request"` + Id int64 `json:"id" v:"required"` +} + +type GetDanceInfoRes model.Dance + +type GetMusicListReq struct { + g.Meta `path:"/musicList" method:"get" tags:"Dance get request"` +} + +type GetMusicListRes []string diff --git a/server/api/dance/v2/dance.go b/server/api/dance/v2/dance.go new file mode 100644 index 0000000..7464a7c --- /dev/null +++ b/server/api/dance/v2/dance.go @@ -0,0 +1,54 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package v2 + +import ( + "encoding/json" + "stackChan/internal/model" + + "github.com/gogf/gf/v2/frame/g" +) + +type GetListReq struct { + g.Meta `path:"/dance" method:"get" tags:"Dance" summary:"Dance get request"` + Mac string `json:"mac" v:"required"` // mac address +} + +type GetListRes []model.Dance + +type CreateReq struct { + g.Meta `path:"/dance" method:"post" tags:"Dance" summary:"Dance create request"` + Mac string `json:"mac" v:"required"` // mac address + DanceData json.RawMessage `json:"danceData"` // Dance motion data + DanceName string `json:"danceName" v:"required"` // Dance name + MusicUrl string `json:"musicUrl"` // Dance background music URL +} + +type CreateRes string + +type DeleteReq struct { + g.Meta `path:"/dance" method:"delete" tags:"Dance" summary:"Dance delete request"` + Id int64 `json:"id" v:"required"` +} + +type DeleteRes string + +type UpdateReq struct { + g.Meta `path:"/dance" method:"put" tags:"Dance" summary:"Dance put request"` + Id int64 `json:"id" v:"required"` + DanceData json.RawMessage `json:"danceData"` // Dance motion data + DanceName string `json:"danceName"` // Dance name + MusicUrl string `json:"musicUrl"` // Dance background music URL +} + +type UpdateRes string + +type GetDanceInfoReq struct { + g.Meta `path:"/danceData" method:"get" tags:"Dance get request"` + Id int64 `json:"id" v:"required"` +} + +type GetDanceInfoRes model.Dance diff --git a/server/api/device/device.go b/server/api/device/device.go index edf8d0f..fc6885e 100644 --- a/server/api/device/device.go +++ b/server/api/device/device.go @@ -8,6 +8,7 @@ import ( "context" "stackChan/api/device/v1" + "stackChan/api/device/v2" ) type IDeviceV1 interface { @@ -17,3 +18,11 @@ type IDeviceV1 interface { GetDeviceInfo(ctx context.Context, req *v1.GetDeviceInfoReq) (res *v1.GetDeviceInfoRes, err error) UpdateDeviceInfo(ctx context.Context, req *v1.UpdateDeviceInfoReq) (res *v1.UpdateDeviceInfoRes, err error) } + +type IDeviceV2 interface { + GetDevices(ctx context.Context, req *v2.GetDevicesReq) (res *v2.GetDevicesRes, err error) + BindDevice(ctx context.Context, req *v2.BindDeviceReq) (res *v2.BindDeviceRes, err error) + UnbindDevice(ctx context.Context, req *v2.UnbindDeviceReq) (res *v2.UnbindDeviceRes, err error) + UpdateDevice(ctx context.Context, req *v2.UpdateDeviceReq) (res *v2.UpdateDeviceRes, err error) + AgentRestoreDefault(ctx context.Context, req *v2.AgentRestoreDefaultReq) (res *v2.AgentRestoreDefaultRes, err error) +} diff --git a/server/api/device/v1/device.go b/server/api/device/v1/device.go index d8f338b..527193a 100644 --- a/server/api/device/v1/device.go +++ b/server/api/device/v1/device.go @@ -14,7 +14,6 @@ import ( type CreateReq struct { g.Meta `path:"/device" method:"post" tags:"Device" summary:"Device create request"` - Mac string `json:"mac" v:"required" description:"Mac address"` Name string `json:"name,omitempty" description:"Device name"` } @@ -24,29 +23,26 @@ type CreateRes struct { type UpdateReq struct { g.Meta `path:"/device" method:"put" tags:"Device" summary:"Device update request"` - Mac string `json:"mac" v:"required" description:"Mac address"` Name string `json:"name" description:"Device name"` } type UpdateRes struct{} type GetRandomDeviceReq struct { - g.Meta `path:"/device/randomList" method:"get" tags:"Device" summary:"Device get Random"` - Mac string `json:"mac" v:"required" description:"Mac address"` + g.Meta `path:"/device/randomList" method:"get" tags:"Device" summary:"Device get Random"` + PageSize int `json:"pageSize" v:"required" d:"6" description:"Page size"` } type GetRandomDeviceRes []entity.Device type GetDeviceInfoReq struct { g.Meta `path:"/device/info" method:"get" tags:"Device" summary:"Device Info Get request"` - Mac string `json:"mac" v:"required" description:"Mac address"` } type GetDeviceInfoRes model.DeviceInfo type UpdateDeviceInfoReq struct { g.Meta `path:"/device/info" method:"put" tags:"Device" summary:"Device Info Put request"` - Mac string `json:"mac" v:"required" description:"Mac address"` Name string `json:"name" description:"Device name"` } diff --git a/server/api/device/v2/device.go b/server/api/device/v2/device.go new file mode 100644 index 0000000..de6a887 --- /dev/null +++ b/server/api/device/v2/device.go @@ -0,0 +1,49 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package v2 + +import ( + "stackChan/internal/model" + + "github.com/gogf/gf/v2/frame/g" +) + +type GetDevicesReq struct { + g.Meta `path:"/devices" method:"get" tags:"Device" summary:"Devices Get request"` +} + +type GetDevicesRes []model.DeviceInfo + +type BindDeviceReq struct { + g.Meta `path:"/device/bind" method:"post" tags:"Device" summary:"Bind device to current user"` + Mac string `json:"mac" v:"required" dc:"Device MAC address"` +} + +type BindDeviceRes bool + +type UnbindDeviceReq struct { + g.Meta `path:"/device/unbind" method:"post" tags:"Device" summary:"Unbind device from current user"` + Mac string `json:"mac" v:"required" dc:"Device MAC address"` +} + +type UnbindDeviceRes bool + +type UpdateDeviceReq struct { + g.Meta `path:"/device/update" method:"put" tags:"Device" summary:"Update device name for current user's bound device"` + Mac string `json:"mac" v:"required" dc:"Device MAC address"` + Name string `json:"name" dc:"New device name"` + Longitude float64 `json:"longitude" dc:"Device longitude"` + Latitude float64 `json:"latitude" dc:"Device latitude"` +} + +type UpdateDeviceRes bool + +type AgentRestoreDefaultReq struct { + g.Meta `path:"/device/agent/restore" method:"post" tags:"Device" summary:"Restore Agent to default template settings"` + Mac string `json:"mac" v:"required" dc:"Device MAC address"` +} + +type AgentRestoreDefaultRes bool diff --git a/server/api/friend/v1/friend.go b/server/api/friend/v1/friend.go index d8162e5..64d57bd 100644 --- a/server/api/friend/v1/friend.go +++ b/server/api/friend/v1/friend.go @@ -9,7 +9,6 @@ import "github.com/gogf/gf/v2/frame/g" type AddReq struct { g.Meta `path:"/friend" method:"post" tags:"Friend" summary:"Friend add request"` - Mac string `json:"mac" v:"required" description:"Mac address"` FriendMac string `json:"friendMac" v:"required" description:"Friend Mac address"` } diff --git a/server/internal/model/do/sqlite_sequence.go b/server/api/pano/pano.go similarity index 50% rename from server/internal/model/do/sqlite_sequence.go rename to server/api/pano/pano.go index a54348b..a64168a 100644 --- a/server/internal/model/do/sqlite_sequence.go +++ b/server/api/pano/pano.go @@ -2,15 +2,15 @@ // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= -package do +package pano import ( - "github.com/gogf/gf/v2/frame/g" + "context" + + "stackChan/api/pano/v1" ) -// SqliteSequence is the golang structure of table sqlite_sequence for DAO operations like Where/Data. -type SqliteSequence struct { - g.Meta `orm:"table:sqlite_sequence, do:true"` - Name any // - Seq any // +type IPanoV1 interface { + AddPano(ctx context.Context, req *v1.AddPanoReq) (res *v1.AddPanoRes, err error) + GetPanoList(ctx context.Context, req *v1.GetPanoListReq) (res *v1.GetPanoListRes, err error) } diff --git a/server/api/pano/v1/pano.go b/server/api/pano/v1/pano.go new file mode 100644 index 0000000..5e4f6e4 --- /dev/null +++ b/server/api/pano/v1/pano.go @@ -0,0 +1,27 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package v1 + +import ( + "stackChan/internal/model" + + "github.com/gogf/gf/v2/frame/g" +) + +type AddPanoReq struct { + g.Meta `path:"/pano" method:"post" tags:"Pano" summary:"Pano add request"` + Url string `json:"url" v:"required" description:"Pano image url"` +} + +type AddPanoRes struct { + Id int64 `json:"id"` +} + +type GetPanoListReq struct { + g.Meta `path:"/pano" method:"get" tags:"Pano" summary:"Pano list"` +} + +type GetPanoListRes []model.Pano diff --git a/server/api/post/v1/post.go b/server/api/post/v1/post.go index 8494915..2370512 100644 --- a/server/api/post/v1/post.go +++ b/server/api/post/v1/post.go @@ -13,9 +13,8 @@ import ( type CreatePostReq struct { g.Meta `path:"/post/add" method:"post" tags:"Post" summary:"Post create request"` - Mac string `json:"mac" v:"required" description:"Mac address"` ContentText string `json:"content_text" v:"required" description:"Content text"` - ContentImage string `json:"content_image" v:"required" description:"Content image"` + ContentImage string `json:"content_image" description:"Content image"` } type CreatePostRes struct { @@ -24,8 +23,8 @@ type CreatePostRes struct { type GetPostReq struct { g.Meta `path:"/post/get" method:"get" tags:"Post" summary:"Post get request"` - Page int `json:"page" v:"required#Page不能为空" description:"页码"` - PageSize int `json:"pageSize" v:"required#每页数量不能为空" description:"每页条数"` + Page int `json:"page" v:"required#Page cannot be empty" description:"Page number"` + PageSize int `json:"pageSize" v:"required#Page size cannot be empty" description:"Items per page"` } type GetPostRes []model.Post @@ -39,9 +38,8 @@ type DeletePostRes string type CreatePostCommentReq struct { g.Meta `path:"/post/comment/create" method:"post" tags:"Post" summary:"Post create comment"` - Mac string `json:"mac" v:"required" description:"Mac address"` PostId int64 `json:"postId" v:"required" summary:"Post comment id"` - Content string `json:"content" description:"评论内容"` + Content string `json:"content" description:"Comment content"` } type CreatePostCommentRes struct { @@ -49,19 +47,18 @@ type CreatePostCommentRes struct { } type DeletePostCommentReq struct { - g.Meta `path:"/post/comment/delete" method:"post" tags:"Post" summary:"Post delete comment"` - Mac string `json:"mac" v:"required" description:"Mac address"` - Id int `json:"id" summary:"Post comment id"` + g.Meta `path:"/post/comment/delete" method:"delete" tags:"Post" summary:"Post delete comment"` + PostId int64 `json:"postId" v:"required" summary:"Post comment id"` + CommentId int `json:"commentId" v:"required" summary:"Post comment id"` } type DeletePostCommentRes struct{} type GetPostCommentReq struct { g.Meta `path:"/post/comment/get" method:"get" tags:"Post" summary:"Post get comment"` - PostId int64 `json:"postId" summary:"Post comment id"` - Mac string `json:"mac" v:"required" description:"Mac address"` - Page int `json:"page" summary:"Post comment page"` - PageSize int `json:"pageSize" summary:"Post comment page"` + PostId int64 `json:"postId" summary:"Post comment id"` + Page int `json:"page" summary:"Post comment page"` + PageSize int `json:"pageSize" summary:"Post comment page"` } type GetPostCommentRes struct { diff --git a/server/api/stackchandevice/stackchandevice.go b/server/api/stackchandevice/stackchandevice.go new file mode 100644 index 0000000..2208046 --- /dev/null +++ b/server/api/stackchandevice/stackchandevice.go @@ -0,0 +1,16 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package stackchandevice + +import ( + "context" + + "stackChan/api/stackchandevice/v2" +) + +type IStackchandeviceV2 interface { + GetDeviceUserInfo(ctx context.Context, req *v2.GetDeviceUserInfoReq) (res *v2.GetDeviceUserInfoRes, err error) + UnbindDevice(ctx context.Context, req *v2.UnbindDeviceReq) (res *v2.UnbindDeviceRes, err error) +} diff --git a/server/api/stackchandevice/v2/device.go b/server/api/stackchandevice/v2/device.go new file mode 100644 index 0000000..7e8083b --- /dev/null +++ b/server/api/stackchandevice/v2/device.go @@ -0,0 +1,24 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package v2 + +import ( + "stackChan/internal/model" + + "github.com/gogf/gf/v2/frame/g" +) + +type GetDeviceUserInfoReq struct { + g.Meta `path:"/device/user" method:"get" tags:"Device" summary:"Get device information for StackChan device"` +} + +type GetDeviceUserInfoRes *model.User + +type UnbindDeviceReq struct { + g.Meta `path:"/device/unbind" method:"post" tags:"Device" summary:"Unbind device from current user for StackChan device"` +} + +type UnbindDeviceRes bool diff --git a/server/api/user/user.go b/server/api/user/user.go new file mode 100644 index 0000000..75adc0e --- /dev/null +++ b/server/api/user/user.go @@ -0,0 +1,17 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package user + +import ( + "context" + + "stackChan/api/user/v2" +) + +type IUserV2 interface { + Login(ctx context.Context, req *v2.LoginReq) (res *v2.LoginRes, err error) + GetUserInfo(ctx context.Context, req *v2.GetUserInfoReq) (res *v2.GetUserInfoRes, err error) + Registration(ctx context.Context, req *v2.RegistrationReq) (res *v2.RegistrationRes, err error) +} diff --git a/server/api/user/v2/user.go b/server/api/user/v2/user.go new file mode 100644 index 0000000..95bf4ad --- /dev/null +++ b/server/api/user/v2/user.go @@ -0,0 +1,37 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package v2 + +import ( + "stackChan/internal/model" + + "github.com/gogf/gf/v2/frame/g" +) + +type LoginReq struct { + g.Meta `path:"user/login" method:"post" tags:"Info" summary:"user login info"` + Username string `json:"username" v:"required" description:"Account or email"` + Password string `json:"password" v:"required" description:"Password"` +} + +type LoginRes struct { + Token string `json:"token"` +} + +type GetUserInfoReq struct { + g.Meta `path:"user" method:"get" tags:"Info" summary:"user get info"` +} + +type GetUserInfoRes model.User + +type RegistrationReq struct { + g.Meta `path:"user/registration" method:"post" tags:"Info" summary:"user registration"` + UserName string `json:"username" v:"required" description:"Username"` + Email string `json:"email" v:"required" description:"Email address"` + Password string `json:"password" v:"required" description:"Password"` +} + +type RegistrationRes *model.RegistrationResponse diff --git a/server/api/xiaozhi/v1/xiaozhi.go b/server/api/xiaozhi/v1/xiaozhi.go new file mode 100644 index 0000000..4a96d6d --- /dev/null +++ b/server/api/xiaozhi/v1/xiaozhi.go @@ -0,0 +1,26 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +type GetXiaoZhiTokenReq struct { + g.Meta `path:"/xiaozhi/token" method:"get" tags:"XiaoZhi" summary:"XiaoZhi token"` +} + +type GetXiaoZhiTokenRes string + +type RefreshTokenReq struct { + g.Meta `path:"/xiaozhi/token/refresh" method:"get" tags:"XiaoZhi" summary:"XiaoZhi token refresh"` +} + +type RefreshTokenRes string + +type GetXiaoZhiGenerateLicenseTokenReq struct { + g.Meta `path:"/xiaozhi/generateLicenseToken" method:"get" tags:"XiaoZhi" summary:"XiaoZhi generateLicenseToken"` +} + +type GetXiaoZhiGenerateLicenseTokenRes string diff --git a/server/api/xiaozhi/xiaozhi.go b/server/api/xiaozhi/xiaozhi.go new file mode 100644 index 0000000..128d966 --- /dev/null +++ b/server/api/xiaozhi/xiaozhi.go @@ -0,0 +1,17 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package xiaozhi + +import ( + "context" + + "stackChan/api/xiaozhi/v1" +) + +type IXiaozhiV1 interface { + GetXiaoZhiToken(ctx context.Context, req *v1.GetXiaoZhiTokenReq) (res *v1.GetXiaoZhiTokenRes, err error) + RefreshToken(ctx context.Context, req *v1.RefreshTokenReq) (res *v1.RefreshTokenRes, err error) + GetXiaoZhiGenerateLicenseToken(ctx context.Context, req *v1.GetXiaoZhiGenerateLicenseTokenReq) (res *v1.GetXiaoZhiGenerateLicenseTokenRes, err error) +} diff --git a/server/check_list/create_mysql_database.sql b/server/check_list/create_mysql_database.sql new file mode 100644 index 0000000..0ccf343 --- /dev/null +++ b/server/check_list/create_mysql_database.sql @@ -0,0 +1,115 @@ +create table stackChan.app_store +( + id bigint auto_increment + primary key, + app_name varchar(128) not null comment 'App 名称', + app_icon_url varchar(512) null comment 'App 图标 URL', + description text null comment 'App 描述信息', + firmware_url varchar(512) null comment '固件 / 安装包下载地址', + create_at datetime default CURRENT_TIMESTAMP null comment '创建时间', + update_at datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间', + is_deleted tinyint(1) default 0 not null comment '是否删除,0 正常 1 删除' +) + comment 'App Store 应用列表表'; + +create table stackChan.device_dance +( + id bigint auto_increment + primary key, + mac varchar(17) not null comment '设备MAC地址', + dance_name varchar(64) null comment '舞蹈名称', + dance_data json not null comment 'MotionData', + music_url varchar(255) null comment '舞蹈背景音乐URL', + created_at datetime default CURRENT_TIMESTAMP null, + updated_at datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP +); + +create table stackChan.device_friend +( + mac_a varchar(17) not null, + mac_b varchar(17) not null, + primary key (mac_a, mac_b) +); + +create index fk_friend_mac_b + on stackChan.device_friend (mac_b); + +create table stackChan.user +( + uid bigint not null comment '用户唯一UID(远程平台主键)' + primary key, + username varchar(64) not null comment '登录用户名', + userslug varchar(64) null comment '用户别名', + display_name varchar(64) null comment '用户显示名称', + icon_text char null comment '用户图标文字', + icon_bg_color varchar(16) null comment '图标背景色', + email_confirmed tinyint(1) default 0 null comment '邮箱是否验证 0-否 1-是', + join_date bigint null comment '注册时间戳(毫秒)', + last_online bigint null comment '最后在线时间戳(毫秒)', + user_status varchar(32) default 'online' null comment '用户在线状态', + create_at datetime default CURRENT_TIMESTAMP null comment '本地创建时间', + update_at datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '本地更新时间', + is_deleted tinyint(1) default 0 not null comment '是否删除 0-正常 1-删除', + constraint idx_username + unique (username) +) + comment '用户信息表'; + +create table stackChan.device +( + mac varchar(17) not null + primary key, + name varchar(255) null, + uid bigint null comment '绑定的用户UID', + bind_time varchar(32) null comment '设备绑定时间', + constraint fk_device_user_uid + foreign key (uid) references stackChan.user (uid) + on update cascade on delete set null +); + +create index idx_device_uid + on stackChan.device (uid); + +create table stackChan.device_pano +( + id bigint auto_increment + primary key, + mac varchar(17) not null comment '设备MAC地址', + pano_url varchar(512) not null comment '全景图URL', + created_at datetime default CURRENT_TIMESTAMP null comment '创建时间', + updated_at datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP, + constraint fk_pano_mac + foreign key (mac) references stackChan.device (mac) + on delete cascade +); + +create index idx_device_pano_mac + on stackChan.device_pano (mac); + +create table stackChan.device_post +( + id bigint auto_increment + primary key, + mac varchar(17) not null comment '发帖设备MAC', + content_text text null, + content_image varchar(512) null comment '图片URL', + created_at datetime default CURRENT_TIMESTAMP null comment '发帖时间', + constraint fk_post_mac + foreign key (mac) references stackChan.device (mac) +); + +create table stackChan.device_post_comment +( + id bigint auto_increment + primary key, + post_id bigint not null comment '帖子ID', + mac varchar(17) not null comment '评论设备MAC', + content text null, + created_at datetime default CURRENT_TIMESTAMP null comment '评论时间', + constraint fk_comment_mac + foreign key (mac) references stackChan.device (mac), + constraint fk_comment_post + foreign key (post_id) references stackChan.device_post (id) + on delete cascade +); + diff --git a/server/file/music/stackchan_music.mp3 b/server/file/music/stackchan_music.mp3 new file mode 100644 index 0000000..55c80a9 Binary files /dev/null and b/server/file/music/stackchan_music.mp3 differ diff --git a/server/go.mod b/server/go.mod index ab51781..edbb42a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,46 +1,45 @@ module stackChan -go 1.24.0 +go 1.26.2 require ( - github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.9.7 - github.com/gogf/gf/v2 v2.9.7 + github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0 + github.com/gogf/gf/v2 v2.10.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 ) require ( + filippo.io/edwards25519 v1.2.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grokify/html-strip-tags-go v0.1.0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/olekukonko/errors v1.1.0 // indirect - github.com/olekukonko/ll v0.0.9 // indirect - github.com/olekukonko/tablewriter v1.1.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.2.0 // indirect + github.com/olekukonko/ll v0.1.8 // indirect + github.com/olekukonko/tablewriter v1.1.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.22.5 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/sqlite v1.23.1 // indirect ) diff --git a/server/go.sum b/server/go.sum deleted file mode 100644 index cce0c82..0000000 --- a/server/go.sum +++ /dev/null @@ -1,100 +0,0 @@ -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= -github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= -github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= -github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.9.7 h1:mzs0MblNT0pOlUB00c/hTcAenQ5N/cB651wh9VCJitc= -github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.9.7/go.mod h1:Z2MgGyag0fZQ2+9ykafD7tQhau9h5ie3Chg/GUzfy5E= -github.com/gogf/gf/v2 v2.9.7 h1:Vp3VGZ7drPs89tZslT6j6BKBTaw7Xs3DMGWx4MlVtMA= -github.com/gogf/gf/v2 v2.9.7/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= -github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= -github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= -github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= -github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= -github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= -github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/server/hack/config.yaml b/server/hack/config.yaml index 4ba7553..eef3184 100644 --- a/server/hack/config.yaml +++ b/server/hack/config.yaml @@ -1,9 +1,7 @@ -#SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD -#SPDX-License-Identifier: MIT gfcli: gen: dao: - - link: "sqlite::@file(/stackChan.sqlite)" + - link: "" descriptionTag: true docker: diff --git a/server/internal/boot/cron.go b/server/internal/boot/cron.go new file mode 100644 index 0000000..1423c59 --- /dev/null +++ b/server/internal/boot/cron.go @@ -0,0 +1,95 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package boot + +import ( + "context" + "stackChan/internal/web_socket" + "sync/atomic" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +func InitCron() { + startPingTimer() + startCleanTimer() +} + +var ( + pingTimerStarted atomic.Bool + cleanTimerStarted atomic.Bool +) + +// startPingTimer starts the heartbeat timer, with panic recovery and restart logic. +func startPingTimer() { + if !pingTimerStarted.CompareAndSwap(false, true) { + return + } + ctx, cancel := context.WithCancel(context.Background()) + _ = cancel // for future use + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + g.Log().Info(ctx, "The heartbeat sending timer has been activated") + for { + select { + case <-ctx.Done(): + pingTimerStarted.Store(false) + return + case <-ticker.C: + func() { + defer func() { + if err := recover(); err != nil { + g.Log().Errorf(ctx, "Heartbeat sending task crash: %v, the timer is about to be restarted", err) + pingTimerStarted.Store(false) + go func() { + time.Sleep(time.Second) + startPingTimer() + }() + } + }() + web_socket.StartPingTime(ctx) + }() + } + } + }() +} + +// startCleanTimer starts the connection cleaning timer, with panic recovery and restart logic. +func startCleanTimer() { + if !cleanTimerStarted.CompareAndSwap(false, true) { + return + } + ctx, cancel := context.WithCancel(context.Background()) + _ = cancel // for future use + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + g.Log().Info(ctx, "The connection cleaning timer has been started") + for { + select { + case <-ctx.Done(): + cleanTimerStarted.Store(false) + return + case <-ticker.C: + func() { + defer func() { + if err := recover(); err != nil { + g.Log().Errorf(ctx, "Connection cleanup task crash: %v, about to restart the timer", err) + cleanTimerStarted.Store(false) + go func() { + time.Sleep(time.Second) + startCleanTimer() + }() + } + }() + web_socket.CheckExpiredLinks(ctx) + }() + } + } + }() +} diff --git a/server/internal/cmd/cmd.go b/server/internal/cmd/cmd.go index ca75193..ae6c84d 100644 --- a/server/internal/cmd/cmd.go +++ b/server/internal/cmd/cmd.go @@ -7,23 +7,27 @@ package cmd import ( "context" - "fmt" - "net" "net/http" "path/filepath" + "stackChan/internal/boot" + "stackChan/internal/controller/admin" + "stackChan/internal/controller/appstore" "stackChan/internal/controller/dance" "stackChan/internal/controller/device" "stackChan/internal/controller/file" "stackChan/internal/controller/friend" + "stackChan/internal/controller/pano" "stackChan/internal/controller/post" + "stackChan/internal/controller/stackchandevice" + "stackChan/internal/controller/user" + "stackChan/internal/controller/xiaozhi" + "stackChan/internal/middleware" "stackChan/internal/web_socket" - "time" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gcmd" "github.com/gogf/gf/v2/os/gfile" - "github.com/gogf/gf/v2/os/gtimer" ) var ( @@ -32,20 +36,16 @@ var ( Usage: "main", Brief: "start http server", Func: func(ctx context.Context, parser *gcmd.Parser) (err error) { - PrintIPAddr() - - //Start a scheduled task to send ping messages - gtimer.SetInterval(ctx, time.Second*5, func(ctx context.Context) { - web_socket.StartPingTime(ctx) - }) - //Start a timer to clean up long-lived connections that have been inactive for a long time on the app. - gtimer.SetInterval(ctx, time.Second*15, func(ctx context.Context) { - web_socket.CheckExpiredLinks(ctx) - }) - s := g.Server() + s.SetClientMaxBodySize(100 * 1024 * 1024) + + s.Use(middleware.CORS) + s.BindHandler("/stackChan/ws", web_socket.Handler) + // heartBeat + boot.InitCron() + ///Configuration file access s.Group("/file", func(group *ghttp.RouterGroup) { group.GET("/*filepath", func(r *ghttp.Request) { @@ -65,28 +65,27 @@ var ( }) }) - s.Group("/stackChan", func(group *ghttp.RouterGroup) { - group.Middleware(ghttp.MiddlewareHandlerResponse) - group.Bind(device.NewV1(), friend.NewV1(), dance.NewV1(), file.NewV1(), post.NewV1()) + s.Group("/stackChan/v2", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.V2TokenAuthMiddleware, ghttp.MiddlewareHandlerResponse) + group.Bind(user.NewV2(), dance.NewV2(), device.NewV2()) }) + + s.Group("/stackChan", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.TokenAuthMiddleware, ghttp.MiddlewareHandlerResponse) + group.Bind(device.NewV1(), friend.NewV1(), dance.NewV1(), file.NewV1(), post.NewV1(), pano.NewV1(), appstore.NewV1(), xiaozhi.NewV1(), stackchandevice.NewV2()) + }) + + s.Group("/admin/stackChan", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.AdminTokenAuthMiddleware, ghttp.MiddlewareHandlerResponse) + group.Bind(admin.NewV1(), file.NewV1()) + }) + + // Do not use SetServerRoot, globally only provide frontend entry via /web + //s.SetServerRoot("web/management") + + s.SetPort(12800) s.Run() return nil }, } ) - -func PrintIPAddr() { - addrs, err := net.InterfaceAddrs() - if err == nil { - fmt.Println("Local IP addresses detected on this machine:") - for _, addr := range addrs { - if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { - fmt.Println(" -", ipnet.IP.String()) - } - } - } else { - fmt.Println("Could not detect local IP addresses:", err) - } - fmt.Println("Please update the StackChan and iOS client access addresses to use one of the above local IPs as needed.") - -} diff --git a/server/internal/controller/admin/admin.go b/server/internal/controller/admin/admin.go new file mode 100644 index 0000000..524f5eb --- /dev/null +++ b/server/internal/controller/admin/admin.go @@ -0,0 +1,6 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package admin diff --git a/server/internal/controller/admin/admin_new.go b/server/internal/controller/admin/admin_new.go new file mode 100644 index 0000000..d8456de --- /dev/null +++ b/server/internal/controller/admin/admin_new.go @@ -0,0 +1,20 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package admin + +import ( + "stackChan/api/admin" +) + +type ControllerV1 struct{} + +func NewV1() admin.IAdminV1 { + return &ControllerV1{} +} diff --git a/server/internal/controller/admin/admin_v1_add_app.go b/server/internal/controller/admin/admin_v1_add_app.go new file mode 100644 index 0000000..7244f5c --- /dev/null +++ b/server/internal/controller/admin/admin_v1_add_app.go @@ -0,0 +1,37 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package admin + +import ( + "context" + "stackChan/api/admin/v1" + "stackChan/internal/dao" + "stackChan/internal/model" + "stackChan/internal/model/do" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" +) + +func (c *ControllerV1) AddApp(ctx context.Context, req *v1.AddAppReq) (res *v1.AddAppRes, err error) { + app := do.AppStore{ + AppName: req.AppName, + AppIconUrl: req.AppIconUrl, + Description: req.Description, + FirmwareUrl: req.FirmwareUrl, + } + id, err := dao.AppStore.Ctx(ctx).Data(&app).InsertAndGetId() + if err != nil { + return nil, gerror.WrapCode(gcode.CodeInternalError, err, "Failed to insert app") + } + var appInfo model.AppInfo + err = dao.AppStore.Ctx(ctx).Where("id", id).Scan(&appInfo) + if err != nil { + return nil, gerror.WrapCode(gcode.CodeInternalError, err, "Failed to fetch inserted app") + } + res = (*v1.AddAppRes)(&appInfo) + return res, nil +} diff --git a/server/internal/controller/admin/admin_v1_admin_login.go b/server/internal/controller/admin/admin_v1_admin_login.go new file mode 100644 index 0000000..9e28e0b --- /dev/null +++ b/server/internal/controller/admin/admin_v1_admin_login.go @@ -0,0 +1,17 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package admin + +import ( + "context" + "stackChan/internal/service" + + "stackChan/api/admin/v1" +) + +func (c *ControllerV1) AdminLogin(ctx context.Context, req *v1.AdminLoginReq) (res *v1.AdminLoginRes, err error) { + return service.AdminLogin(ctx, req) +} diff --git a/server/internal/controller/admin/admin_v1_delete_app.go b/server/internal/controller/admin/admin_v1_delete_app.go new file mode 100644 index 0000000..ae6de79 --- /dev/null +++ b/server/internal/controller/admin/admin_v1_delete_app.go @@ -0,0 +1,28 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package admin + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + + "stackChan/api/admin/v1" + "stackChan/internal/dao" + + "github.com/gogf/gf/v2/frame/g" +) + +func (c *ControllerV1) DeleteApp(ctx context.Context, req *v1.DeleteAppReq) (res *v1.DeleteAppRes, err error) { + _, err = dao.AppStore.Ctx(ctx).Where("id", req.Id).Data(g.Map{"is_deleted": 1}).Update() + if err != nil { + return nil, gerror.WrapCode(gcode.CodeInternalError, err, "Failed to delete app") + } + + res = &v1.DeleteAppRes{} + return res, nil +} diff --git a/server/internal/controller/admin/admin_v1_get_app_list.go b/server/internal/controller/admin/admin_v1_get_app_list.go new file mode 100644 index 0000000..37d5c0e --- /dev/null +++ b/server/internal/controller/admin/admin_v1_get_app_list.go @@ -0,0 +1,22 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package admin + +import ( + "context" + "stackChan/internal/service" + + "stackChan/api/admin/v1" +) + +func (c *ControllerV1) GetAppList(ctx context.Context, req *v1.GetAppListReq) (res *v1.GetAppListRes, err error) { + apps, err := service.GetAppList(ctx) + if err != nil { + return nil, err + } + response := v1.GetAppListRes(apps) + return &response, nil +} diff --git a/server/internal/controller/admin/admin_v1_update_app.go b/server/internal/controller/admin/admin_v1_update_app.go new file mode 100644 index 0000000..e004a23 --- /dev/null +++ b/server/internal/controller/admin/admin_v1_update_app.go @@ -0,0 +1,50 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package admin + +import ( + "context" + "stackChan/internal/model" + + "stackChan/api/admin/v1" + "stackChan/internal/dao" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +func (c *ControllerV1) UpdateApp(ctx context.Context, req *v1.UpdateAppReq) (res *v1.UpdateAppRes, err error) { + updateData := g.Map{} + if req.AppName != "" { + updateData["app_name"] = req.AppName + } + if req.AppIconUrl != "" { + updateData["app_icon_url"] = req.AppIconUrl + } + if req.Description != "" { + updateData["description"] = req.Description + } + if req.FirmwareUrl != "" { + updateData["firmware_url"] = req.FirmwareUrl + } + + if len(updateData) > 0 { + _, err = dao.AppStore.Ctx(ctx).WherePri(req.Id).Data(updateData).Update() + if err != nil { + return nil, gerror.WrapCode(gcode.CodeInternalError, err, "Failed to update app") + } + } + + var appInfo model.AppInfo + err = dao.AppStore.Ctx(ctx).WherePri(req.Id).Scan(&appInfo) + if err != nil { + return nil, gerror.WrapCode(gcode.CodeInternalError, err, "Failed to fetch updated app") + } + + res = (*v1.UpdateAppRes)(&appInfo) + return res, nil +} diff --git a/server/internal/controller/appstore/appstore.go b/server/internal/controller/appstore/appstore.go new file mode 100644 index 0000000..55fa765 --- /dev/null +++ b/server/internal/controller/appstore/appstore.go @@ -0,0 +1,10 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package appstore diff --git a/server/internal/controller/appstore/appstore_new.go b/server/internal/controller/appstore/appstore_new.go new file mode 100644 index 0000000..c84b0e9 --- /dev/null +++ b/server/internal/controller/appstore/appstore_new.go @@ -0,0 +1,20 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package appstore + +import ( + "stackChan/api/appstore" +) + +type ControllerV1 struct{} + +func NewV1() appstore.IAppstoreV1 { + return &ControllerV1{} +} diff --git a/server/internal/controller/appstore/appstore_v1_get_app_list.go b/server/internal/controller/appstore/appstore_v1_get_app_list.go new file mode 100644 index 0000000..98db5a2 --- /dev/null +++ b/server/internal/controller/appstore/appstore_v1_get_app_list.go @@ -0,0 +1,22 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package appstore + +import ( + "context" + "stackChan/internal/service" + + "stackChan/api/appstore/v1" +) + +func (c *ControllerV1) GetAppList(ctx context.Context, req *v1.GetAppListReq) (res *v1.GetAppListRes, err error) { + apps, err := service.GetAppList(ctx) + if err != nil { + return nil, err + } + response := v1.GetAppListRes(apps) + return &response, nil +} diff --git a/server/internal/controller/dance/dance_new.go b/server/internal/controller/dance/dance_new.go index fbf8156..6f80c08 100644 --- a/server/internal/controller/dance/dance_new.go +++ b/server/internal/controller/dance/dance_new.go @@ -17,3 +17,9 @@ type ControllerV1 struct{} func NewV1() dance.IDanceV1 { return &ControllerV1{} } + +type ControllerV2 struct{} + +func NewV2() dance.IDanceV2 { + return &ControllerV2{} +} diff --git a/server/internal/controller/dance/dance_v1_create.go b/server/internal/controller/dance/dance_v1_create.go index 430cca8..1692260 100644 --- a/server/internal/controller/dance/dance_v1_create.go +++ b/server/internal/controller/dance/dance_v1_create.go @@ -7,60 +7,75 @@ package dance import ( "context" - "encoding/json" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/internal/model/do" "stackChan/api/dance/v1" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) Create(ctx context.Context, req *v1.CreateReq) (res *v1.CreateRes, err error) { - if req.Index < 0 { - return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Index cannot be negative") + // 1. Get and validate MAC address (business required parameter) + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "MAC address cannot be empty") } - - device, err := dao.Device.Ctx(ctx).Where("mac=", req.Mac).One() - - if err != nil { - return nil, err + // 2. Auto validate using struct v tag (DanceName required), manual secondary validation as fallback + if req.DanceName == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Dance name cannot be empty") } - - if device.IsEmpty() { - _, err = dao.Device.Ctx(ctx).Data(dao.Device.Columns().Mac, req.Mac).Insert() - if err != nil { - return nil, err + // 3. Validate dance data not empty (RawMessage need to check if empty/only null) + if len(req.DanceData) == 0 || string(req.DanceData) == "null" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Dance data cannot be empty or null") + } + // 4. Use transaction to ensure data consistency + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + // 4.1 Query device, create if not exists + device, err := dao.Device.Ctx(ctx).TX(tx).Where("mac=?", mac).One() + if err != nil && !gerror.HasCode(err, gcode.CodeNotFound) { + return gerror.NewCode(gcode.CodeInternalError, "Failed to query device: %v", err.Error()) } - } - dance, err := dao.DeviceDance.Ctx(ctx).Where("mac=?", req.Mac).Where("dance_index=?", req.Index).One() - if err != nil { - return nil, err - } + // Create device if not exists + if device.IsEmpty() { + _, err = dao.Device.Ctx(ctx).TX(tx).Data(dao.Device.Columns().Mac, mac).Insert() + if err != nil { + return gerror.NewCode(gcode.CodeInternalError, "Failed to create device: %v", err.Error()) + } + } - danceListJSON, err := json.Marshal(req.List) - if err != nil { - return nil, err - } + // 4.2 Check if dance data with same MAC+DanceName exists (avoid duplicates) + exist, err := dao.DeviceDance.Ctx(ctx).TX(tx). + Where("mac=?", mac). + Where("dance_name=?", req.DanceName). + Exist() + if err != nil { + return gerror.NewCode(gcode.CodeInternalError, "Failed to check duplicate dance data: %v", err.Error()) + } + if exist { + return gerror.NewCode(gcode.CodeBusinessValidationFailed, "Dance data with MAC %s and name '%s' already exists", mac, req.DanceName) + } - if dance.IsEmpty() { - _, err = dao.DeviceDance.Ctx(ctx).Data(do.DeviceDance{ - Mac: req.Mac, - DanceIndex: req.Index, - DanceData: danceListJSON, + // 4.3 Insert dance data (use RawMessage directly, no need for secondary serialization) + _, err = dao.DeviceDance.Ctx(ctx).TX(tx).Data(do.DeviceDance{ + Mac: mac, + DanceData: req.DanceData, // Use RawMessage directly, avoid duplicate marshal + DanceName: req.DanceName, + MusicUrl: req.MusicUrl, // Add background music URL field }).Insert() if err != nil { - return nil, err - } - } else { - _, err = dao.DeviceDance.Ctx(ctx).Where("mac=?", req.Mac).Where("dance_index=?", req.Index).Data(do.DeviceDance{ - DanceData: danceListJSON, - }).Update() - if err != nil { - return nil, err + return gerror.NewCode(gcode.CodeInternalError, "Failed to insert dance data: %v", err.Error()) } + + return nil + }) + if err != nil { + return nil, err } response := v1.CreateRes("Dance data saved successfully") return &response, nil diff --git a/server/internal/controller/dance/dance_v1_delete.go b/server/internal/controller/dance/dance_v1_delete.go index 4d85354..01fafec 100644 --- a/server/internal/controller/dance/dance_v1_delete.go +++ b/server/internal/controller/dance/dance_v1_delete.go @@ -8,11 +8,20 @@ package dance import ( "context" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/api/dance/v1" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) Delete(ctx context.Context, req *v1.DeleteReq) (res *v1.DeleteRes, err error) { - _, err = dao.DeviceDance.Ctx(ctx).Where("mac=", req.Mac).Where("dance_index=", req.Index).Delete() + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + _, err = dao.DeviceDance.Ctx(ctx).Where("id=", req.Id).Delete() return } diff --git a/server/internal/controller/dance/dance_v1_get_dance.go b/server/internal/controller/dance/dance_v1_get_dance.go new file mode 100644 index 0000000..6095770 --- /dev/null +++ b/server/internal/controller/dance/dance_v1_get_dance.go @@ -0,0 +1,34 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package dance + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/dance/v1" +) + +func (c *ControllerV1) GetDanceInfo(ctx context.Context, req *v1.GetDanceInfoReq) (res *v1.GetDanceInfoRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + if req.Id == 0 { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "The dance ID cannot be left blank.") + } + var dance model.Dance + err = dao.DeviceDance.Ctx(ctx).Where("mac=?", mac).Where("id=?", req.Id).Scan(&dance) + if err != nil { + return nil, gerror.NewCode(gcode.CodeInternalError) + } + return new(v1.GetDanceInfoRes(dance)), nil +} diff --git a/server/internal/controller/dance/dance_v1_get_list.go b/server/internal/controller/dance/dance_v1_get_list.go index b952c6a..6f7c59f 100644 --- a/server/internal/controller/dance/dance_v1_get_list.go +++ b/server/internal/controller/dance/dance_v1_get_list.go @@ -7,38 +7,52 @@ package dance import ( "context" - "encoding/json" "stackChan/internal/dao" "stackChan/internal/model" "stackChan/internal/model/do" - "stackChan/internal/model/entity" - "strconv" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "stackChan/api/dance/v1" ) func (c *ControllerV1) GetList(ctx context.Context, req *v1.GetListReq) (res *v1.GetListRes, err error) { - danceMap := make(map[string][]model.DanceData) - var list []entity.DeviceDance + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + + var danceList []model.Dance err = dao.DeviceDance.Ctx(ctx).Where(do.DeviceDance{ - Mac: req.Mac, - }).Scan(&list) + Mac: mac, + }).Scan(&danceList) if err != nil { return nil, err } - if len(list) > 0 { - deviceDance := list[0] - var danceList []model.DanceData - err = json.Unmarshal([]byte(deviceDance.DanceData), &danceList) - if err != nil { - return nil, gerror.WrapCode(gcode.CodeInvalidParameter, err) + + // Core modification: insert default data only when query result is empty + if len(danceList) == 0 { + // Insert single default dance data + defaultDance := do.DeviceDance{ + Mac: mac, + MusicUrl: "file/music/stackchan_music.mp3", + DanceData: model.DefaultDanceData, + } + _, err = dao.DeviceDance.Ctx(ctx).Data(defaultDance).Insert() + if err != nil { + return nil, err + } + + // Re-query list (one data exists now) + err = dao.DeviceDance.Ctx(ctx).Where(do.DeviceDance{ + Mac: mac, + }).Scan(&danceList) + if err != nil { + return nil, err } - key := strconv.Itoa(deviceDance.DanceIndex) - danceMap[key] = danceList } - response := v1.GetListRes(danceMap) - return &response, nil + + return new(v1.GetListRes(danceList)), nil } diff --git a/server/internal/controller/dance/dance_v1_get_music_list.go b/server/internal/controller/dance/dance_v1_get_music_list.go new file mode 100644 index 0000000..143308b --- /dev/null +++ b/server/internal/controller/dance/dance_v1_get_music_list.go @@ -0,0 +1,17 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package dance + +import ( + "context" + "stackChan/api/dance/v1" +) + +func (c *ControllerV1) GetMusicList(ctx context.Context, req *v1.GetMusicListReq) (res *v1.GetMusicListRes, err error) { + var list = make([]string, 1) + list = append(list, "file/music/stackchan_music.mp3") + return new(v1.GetMusicListRes(list)), nil +} diff --git a/server/internal/controller/dance/dance_v1_update.go b/server/internal/controller/dance/dance_v1_update.go index 05cc242..a112c12 100644 --- a/server/internal/controller/dance/dance_v1_update.go +++ b/server/internal/controller/dance/dance_v1_update.go @@ -9,23 +9,42 @@ import ( "context" "encoding/json" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/internal/model/do" "stackChan/api/dance/v1" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) Update(ctx context.Context, req *v1.UpdateReq) (res *v1.UpdateRes, err error) { - response := v1.UpdateRes("") - danceJSON, err := json.Marshal(req.Data) + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "MAC address cannot be empty") + } + if req.Id == 0 { // Adjust based on actual type of req.Id (int/string) + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Dance ID cannot be empty") + } + updateData := do.DeviceDance{} + if req.MusicUrl != "" { + updateData.MusicUrl = req.MusicUrl + } + if req.DanceName != "" { + updateData.DanceName = req.DanceName + } + if req.DanceData != nil { + danceJSON, err := json.Marshal(req.DanceData) + if err != nil { + // Wrap serialization error, add business prompt + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Dance data serialization failed: %v") + } + updateData.DanceData = danceJSON + } + _, err = dao.DeviceDance.Ctx(ctx).Where("mac=?", mac).Where("id=?", req.Id).Data(updateData).Update() if err != nil { return nil, err } - _, err = dao.DeviceDance.Ctx(ctx).Where("mac=?", req.Mac).Where("dance_index=?", req.Index).Data(do.DeviceDance{ - DanceData: danceJSON, - }).Update() - if err != nil { - return nil, err - } - response = "Update successful" - return &response, nil + return new(v1.UpdateRes("Update successful")), nil } diff --git a/server/internal/controller/dance/dance_v2_create.go b/server/internal/controller/dance/dance_v2_create.go new file mode 100644 index 0000000..72a2f16 --- /dev/null +++ b/server/internal/controller/dance/dance_v2_create.go @@ -0,0 +1,66 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package dance + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model/do" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/dance/v2" +) + +func (c *ControllerV2) Create(ctx context.Context, req *v2.CreateReq) (res *v2.CreateRes, err error) { + mac := req.Mac + if req.DanceName == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Dance name cannot be empty") + } + if len(req.DanceData) == 0 || string(req.DanceData) == "null" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Dance data cannot be empty or null") + } + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + device, err := dao.Device.Ctx(ctx).TX(tx).Where("mac=?", mac).One() + if err != nil && !gerror.HasCode(err, gcode.CodeNotFound) { + return gerror.NewCode(gcode.CodeInternalError, "Failed to query device: %v", err.Error()) + } + if device.IsEmpty() { + _, err = dao.Device.Ctx(ctx).TX(tx).Data(dao.Device.Columns().Mac, mac).Insert() + if err != nil { + return gerror.NewCode(gcode.CodeInternalError, "Failed to create device: %v", err.Error()) + } + } + exist, err := dao.DeviceDance.Ctx(ctx).TX(tx). + Where("mac=?", mac). + Where("dance_name=?", req.DanceName). + Exist() + if err != nil { + return gerror.NewCode(gcode.CodeInternalError, "Failed to check duplicate dance data: %v", err.Error()) + } + if exist { + return gerror.NewCode(gcode.CodeBusinessValidationFailed, "Dance data with MAC %s and name '%s' already exists", mac, req.DanceName) + } + _, err = dao.DeviceDance.Ctx(ctx).TX(tx).Data(do.DeviceDance{ + Mac: mac, + DanceData: req.DanceData, + DanceName: req.DanceName, + MusicUrl: req.MusicUrl, + }).Insert() + if err != nil { + return gerror.NewCode(gcode.CodeInternalError, "Failed to insert dance data: %v", err.Error()) + } + + return nil + }) + if err != nil { + return nil, err + } + return new(v2.CreateRes("Dance data saved successfully")), nil +} diff --git a/server/internal/controller/dance/dance_v2_delete.go b/server/internal/controller/dance/dance_v2_delete.go new file mode 100644 index 0000000..38a9591 --- /dev/null +++ b/server/internal/controller/dance/dance_v2_delete.go @@ -0,0 +1,18 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package dance + +import ( + "context" + "stackChan/internal/dao" + + "stackChan/api/dance/v2" +) + +func (c *ControllerV2) Delete(ctx context.Context, req *v2.DeleteReq) (res *v2.DeleteRes, err error) { + _, err = dao.DeviceDance.Ctx(ctx).Where("id=", req.Id).Delete() + return +} diff --git a/server/internal/controller/dance/dance_v2_get_dance_info.go b/server/internal/controller/dance/dance_v2_get_dance_info.go new file mode 100644 index 0000000..f82d4fb --- /dev/null +++ b/server/internal/controller/dance/dance_v2_get_dance_info.go @@ -0,0 +1,29 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package dance + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + + "stackChan/api/dance/v2" +) + +func (c *ControllerV2) GetDanceInfo(ctx context.Context, req *v2.GetDanceInfoReq) (res *v2.GetDanceInfoRes, err error) { + if req.Id == 0 { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "The dance ID cannot be left blank.") + } + var dance model.Dance + err = dao.DeviceDance.Ctx(ctx).Where("id=?", req.Id).Scan(&dance) + if err != nil { + return nil, gerror.NewCode(gcode.CodeInternalError) + } + return new(v2.GetDanceInfoRes(dance)), nil +} diff --git a/server/internal/controller/dance/dance_v2_get_list.go b/server/internal/controller/dance/dance_v2_get_list.go new file mode 100644 index 0000000..bf0f331 --- /dev/null +++ b/server/internal/controller/dance/dance_v2_get_list.go @@ -0,0 +1,50 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package dance + +import ( + "context" + "stackChan/api/dance/v2" + "stackChan/internal/dao" + "stackChan/internal/model" + "stackChan/internal/model/do" +) + +func (c *ControllerV2) GetList(ctx context.Context, req *v2.GetListReq) (res *v2.GetListRes, err error) { + mac := req.Mac + var danceList []model.Dance + err = dao.DeviceDance.Ctx(ctx).Where(do.DeviceDance{ + Mac: mac, + }).Scan(&danceList) + if err != nil { + return nil, err + } + + // Core modification: insert default data only when query result is empty + if len(danceList) == 0 { + // Insert single default dance data + defaultDance := do.DeviceDance{ + DanceName: "StackChan Dance", + Mac: mac, + MusicUrl: "http://47.113.125.164:12800/file/music/stackchan_music.mp3", + DanceData: model.DefaultDanceData, + } + _, err = dao.DeviceDance.Ctx(ctx).Data(defaultDance).Insert() + if err != nil { + return nil, err + } + + // Re-query list (one data exists now) + err = dao.DeviceDance.Ctx(ctx).Where(do.DeviceDance{ + Mac: mac, + }).Scan(&danceList) + if err != nil { + return nil, err + } + } + + return new(v2.GetListRes(danceList)), nil +} diff --git a/server/internal/controller/dance/dance_v2_update.go b/server/internal/controller/dance/dance_v2_update.go new file mode 100644 index 0000000..dfa730d --- /dev/null +++ b/server/internal/controller/dance/dance_v2_update.go @@ -0,0 +1,44 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package dance + +import ( + "context" + "encoding/json" + "stackChan/internal/dao" + "stackChan/internal/model/do" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + + "stackChan/api/dance/v2" +) + +func (c *ControllerV2) Update(ctx context.Context, req *v2.UpdateReq) (res *v2.UpdateRes, err error) { + if req.Id == 0 { // Adjust based on actual type of req.Id (int/string) + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Dance ID cannot be empty") + } + updateData := do.DeviceDance{} + if req.MusicUrl != "" { + updateData.MusicUrl = req.MusicUrl + } + if req.DanceName != "" { + updateData.DanceName = req.DanceName + } + if req.DanceData != nil { + danceJSON, err := json.Marshal(req.DanceData) + if err != nil { + // Wrap serialization error, add business prompt + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Dance data serialization failed: %v") + } + updateData.DanceData = danceJSON + } + _, err = dao.DeviceDance.Ctx(ctx).Where("id=?", req.Id).Data(updateData).Update() + if err != nil { + return nil, err + } + return new(v2.UpdateRes("Update successful")), nil +} diff --git a/server/internal/controller/device/device_new.go b/server/internal/controller/device/device_new.go index d23b6dc..0118800 100644 --- a/server/internal/controller/device/device_new.go +++ b/server/internal/controller/device/device_new.go @@ -18,3 +18,9 @@ type ControllerV1 struct{} func NewV1() device.IDeviceV1 { return &ControllerV1{} } + +type ControllerV2 struct{} + +func NewV2() device.IDeviceV2 { + return &ControllerV2{} +} diff --git a/server/internal/controller/device/device_v1_create.go b/server/internal/controller/device/device_v1_create.go index 316abe9..e9fe0b7 100644 --- a/server/internal/controller/device/device_v1_create.go +++ b/server/internal/controller/device/device_v1_create.go @@ -9,12 +9,21 @@ import ( "context" "stackChan/api/device/v1" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/internal/model/do" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) Create(ctx context.Context, req *v1.CreateReq) (res *v1.CreateRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } insertId, err := dao.Device.Ctx(ctx).Data(do.Device{ - Mac: req.Mac, + Mac: mac, Name: req.Name, }).InsertAndGetId() if err != nil { diff --git a/server/internal/controller/device/device_v1_get_device_info.go b/server/internal/controller/device/device_v1_get_device_info.go index 6a317ea..138d8b5 100644 --- a/server/internal/controller/device/device_v1_get_device_info.go +++ b/server/internal/controller/device/device_v1_get_device_info.go @@ -11,11 +11,19 @@ import ( "stackChan/internal/model" "stackChan/api/device/v1" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) GetDeviceInfo(ctx context.Context, req *v1.GetDeviceInfoReq) (res *v1.GetDeviceInfoRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } var info model.DeviceInfo - err = dao.Device.Ctx(ctx).WherePri(req.Mac).Scan(&info) + err = dao.Device.Ctx(ctx).WherePri(mac).Scan(&info) if err != nil { return nil, err } diff --git a/server/internal/controller/device/device_v1_get_random_device.go b/server/internal/controller/device/device_v1_get_random_device.go index 8a56f5b..81603a0 100644 --- a/server/internal/controller/device/device_v1_get_random_device.go +++ b/server/internal/controller/device/device_v1_get_random_device.go @@ -9,14 +9,28 @@ import ( "context" "stackChan/api/device/v1" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/internal/model/entity" "stackChan/internal/web_socket" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) GetRandomDevice(ctx context.Context, req *v1.GetRandomDeviceReq) (res *v1.GetRandomDeviceRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = 6 + } // Obtain the list of online StackChan mac addresses (excluding the current user) from the websocket layer. - macList := web_socket.GetRandomStackChanDevice(req.Mac, 6) + macList := web_socket.GetRandomStackChanDevice(mac, pageSize) if len(macList) == 0 { res = (*v1.GetRandomDeviceRes)(&[]entity.Device{}) diff --git a/server/internal/controller/device/device_v1_update.go b/server/internal/controller/device/device_v1_update.go index 5dcc21b..7f3c096 100644 --- a/server/internal/controller/device/device_v1_update.go +++ b/server/internal/controller/device/device_v1_update.go @@ -8,14 +8,23 @@ package device import ( "context" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/internal/model/do" "stackChan/api/device/v1" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) Update(ctx context.Context, req *v1.UpdateReq) (res *v1.UpdateRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } _, err = dao.Device.Ctx(ctx).Data(do.Device{ Name: req.Name, - }).WherePri(req.Mac).Update() + }).WherePri(mac).Update() return } diff --git a/server/internal/controller/device/device_v1_update_device_info.go b/server/internal/controller/device/device_v1_update_device_info.go index 52dc4ed..c70edb2 100644 --- a/server/internal/controller/device/device_v1_update_device_info.go +++ b/server/internal/controller/device/device_v1_update_device_info.go @@ -8,17 +8,26 @@ package device import ( "context" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/internal/model/do" "stackChan/api/device/v1" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) UpdateDeviceInfo(ctx context.Context, req *v1.UpdateDeviceInfoReq) (res *v1.UpdateDeviceInfoRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } doDevice := do.Device{} if req.Name != "" { doDevice.Name = req.Name } - _, err = dao.Device.Ctx(ctx).Data(doDevice).WherePri(req.Mac).Update() + _, err = dao.Device.Ctx(ctx).Data(doDevice).WherePri(mac).Update() if err != nil { return nil, err } diff --git a/server/internal/controller/device/device_v2_agent_restore_default.go b/server/internal/controller/device/device_v2_agent_restore_default.go new file mode 100644 index 0000000..64c03d2 --- /dev/null +++ b/server/internal/controller/device/device_v2_agent_restore_default.go @@ -0,0 +1,26 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package device + +import ( + "context" + "stackChan/api/device/v2" +) + +func (c *ControllerV2) AgentRestoreDefault(ctx context.Context, req *v2.AgentRestoreDefaultReq) (res *v2.AgentRestoreDefaultRes, err error) { + return new(v2.AgentRestoreDefaultRes(true)), err + //if req.Mac == "" { + // return nil, gerror.NewCode(gcode.CodeMissingParameter, "Device MAC address cannot be empty") + //} + //restoreResponse, err := service.RestoreDefaultAgent(req.Mac) + //if err != nil { + // return nil, err + //} + //if !restoreResponse { + // return nil, gerror.NewCode(gcode.CodeInternalError, "Failed to restore default configuration") + //} + //return new(v2.AgentRestoreDefaultRes(true)), nil +} diff --git a/server/internal/controller/device/device_v2_bind_device.go b/server/internal/controller/device/device_v2_bind_device.go new file mode 100644 index 0000000..9f26dab --- /dev/null +++ b/server/internal/controller/device/device_v2_bind_device.go @@ -0,0 +1,41 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package device + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + "stackChan/internal/service" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "stackChan/api/device/v2" +) + +// BindDevice Device binding interface +func (c *ControllerV2) BindDevice(ctx context.Context, req *v2.BindDeviceReq) (res *v2.BindDeviceRes, err error) { + // 1. Get current logged-in user UID (from context) + _, err = service.CreateMacIfNotExists(ctx, req.Mac) + uid := g.RequestFromCtx(ctx).GetCtxVar(model.Uid).Int64() + if uid == 0 { + return nil, gerror.NewCode(gcode.CodeMissingParameter, "User UID cannot be empty") + } + if req.Mac == "" { + return nil, gerror.NewCode(gcode.CodeMissingParameter, "Device MAC address cannot be empty") + } + _, err = dao.Device.Ctx(ctx). + Where("mac = ?", req.Mac). + Data("uid", uid, "bind_time", gtime.Now().Format("Y-m-d H:i:s")). + Update() + if err != nil { + return nil, gerror.WrapCode(gcode.CodeDbOperationError, err, "Device binding failed") + } + return new(v2.BindDeviceRes(true)), nil +} diff --git a/server/internal/controller/device/device_v2_get_devices.go b/server/internal/controller/device/device_v2_get_devices.go new file mode 100644 index 0000000..160d276 --- /dev/null +++ b/server/internal/controller/device/device_v2_get_devices.go @@ -0,0 +1,31 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package device + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/device/v2" +) + +func (c *ControllerV2) GetDevices(ctx context.Context, req *v2.GetDevicesReq) (res *v2.GetDevicesRes, err error) { + uid := g.RequestFromCtx(ctx).GetCtxVar(model.Uid).Int64() + if uid == 0 { + return nil, gerror.NewCode(gcode.CodeMissingParameter, "user UID is required") + } + devices := make([]model.DeviceInfo, 0) + err = dao.Device.Ctx(ctx).Where("uid=?", uid).Scan(&devices) + if err != nil { + return nil, err + } + return new(v2.GetDevicesRes(devices)), nil +} diff --git a/server/internal/controller/device/device_v2_unbind_device.go b/server/internal/controller/device/device_v2_unbind_device.go new file mode 100644 index 0000000..610f77b --- /dev/null +++ b/server/internal/controller/device/device_v2_unbind_device.go @@ -0,0 +1,69 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package device + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + "stackChan/internal/service" + "stackChan/internal/xiaozhi" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/device/v2" +) + +// UnbindDevice Device unbinding interface +func (c *ControllerV2) UnbindDevice(ctx context.Context, req *v2.UnbindDeviceReq) (res *v2.UnbindDeviceRes, err error) { + _, err = service.CreateMacIfNotExists(ctx, req.Mac) + if err != nil { + return nil, gerror.WrapCode(gcode.CodeDbOperationError, err, "Failed to initialize device information") + } + uid := g.RequestFromCtx(ctx).GetCtxVar(model.Uid).Int64() + if uid == 0 { + return nil, gerror.NewCode(gcode.CodeMissingParameter, "User UID cannot be empty") + } + + // 3. Validate MAC address parameter + if req.Mac == "" { + return nil, gerror.NewCode(gcode.CodeMissingParameter, "Device MAC address cannot be empty") + } + + restoreResponse, err := service.RestoreDefaultAgent(req.Mac) + + if err != nil { + return nil, err + } + + if !restoreResponse { + return nil, gerror.NewCode(gcode.CodeInternalError, "Failed to restore default configuration") + } + + // xiaozhi Unbind Device + unbindResponse, err := xiaozhi.UnbindDevice(&req.Mac) + if err != nil { + return nil, gerror.NewCode(gcode.CodeInternalError) + } + if !unbindResponse { + g.Log().Error(ctx, "xiaozhi Unbind Device failed:") + return nil, gerror.NewCode(gcode.CodeInternalError) + } + + // 4. Perform unbind: set uid to 0/NULL (only the current user's own device can be unbound) + _, err = dao.Device.Ctx(ctx). + Where("mac = ?", req.Mac). + Where("uid = ?", uid). + Data("uid", nil, "bind_time", nil). + Update() + if err != nil { + return nil, gerror.WrapCode(gcode.CodeDbOperationError, err, "Device unbinding failed") + } + // 5. Return success response (consistent with bind interface format) + return new(v2.UnbindDeviceRes(true)), nil +} diff --git a/server/internal/controller/device/device_v2_update_device.go b/server/internal/controller/device/device_v2_update_device.go new file mode 100644 index 0000000..ed397c6 --- /dev/null +++ b/server/internal/controller/device/device_v2_update_device.go @@ -0,0 +1,67 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package device + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/device/v2" +) + +func (c *ControllerV2) UpdateDevice(ctx context.Context, req *v2.UpdateDeviceReq) (res *v2.UpdateDeviceRes, err error) { + // Get current logged-in user UID + uid := g.RequestFromCtx(ctx).GetCtxVar(model.Uid).Int64() + if uid == 0 { + return nil, gerror.NewCode(gcode.CodeMissingParameter, "User UID cannot be empty") + } + + // check device exists and belongs to current user + count, err := dao.Device.Ctx(ctx). + Where("mac = ?", req.Mac). + Where("uid = ?", uid). + Count() + if err != nil { + return nil, gerror.WrapCode(gcode.CodeDbOperationError, err, "Failed to query device information") + } + if count == 0 { + return nil, gerror.NewCode(gcode.CodeNotFound, "device not found or not belong to current user") + } + + // build update data + updateData := g.Map{} + if req.Name != "" { + updateData["name"] = req.Name + } + if req.Longitude != 0 { + updateData["longitude"] = req.Longitude + } + if req.Latitude != 0 { + updateData["latitude"] = req.Latitude + } + + // no need to update + if len(updateData) == 0 { + return new(v2.UpdateDeviceRes(true)), nil + } + + // update device information + _, err = dao.Device.Ctx(ctx). + Where("mac = ?", req.Mac). + Where("uid = ?", uid). + Data(updateData). + Update() + if err != nil { + return nil, gerror.WrapCode(gcode.CodeDbOperationError, err, "update device failed") + } + + return new(v2.UpdateDeviceRes(true)), nil +} diff --git a/server/internal/controller/friend/friend_v1_add.go b/server/internal/controller/friend/friend_v1_add.go index 0255381..4c78019 100644 --- a/server/internal/controller/friend/friend_v1_add.go +++ b/server/internal/controller/friend/friend_v1_add.go @@ -8,18 +8,25 @@ package friend import ( "context" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/internal/model/entity" + "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "stackChan/api/friend/v1" ) func (c *ControllerV1) Add(ctx context.Context, req *v1.AddReq) (res *v1.AddRes, err error) { - if req.Mac == req.FriendMac { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + if mac == req.FriendMac { return nil, gerror.New("You cannot add yourself as a friend") } - macA := req.Mac + macA := mac macB := req.FriendMac var friend entity.DeviceFriend err = dao.DeviceFriend.Ctx(ctx). diff --git a/server/internal/controller/pano/pano.go b/server/internal/controller/pano/pano.go new file mode 100644 index 0000000..e8f3331 --- /dev/null +++ b/server/internal/controller/pano/pano.go @@ -0,0 +1,10 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package pano diff --git a/server/internal/controller/pano/pano_new.go b/server/internal/controller/pano/pano_new.go new file mode 100644 index 0000000..7a6dd82 --- /dev/null +++ b/server/internal/controller/pano/pano_new.go @@ -0,0 +1,20 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package pano + +import ( + "stackChan/api/pano" +) + +type ControllerV1 struct{} + +func NewV1() pano.IPanoV1 { + return &ControllerV1{} +} diff --git a/server/internal/controller/pano/pano_v1_add_pano.go b/server/internal/controller/pano/pano_v1_add_pano.go new file mode 100644 index 0000000..2f9c6a8 --- /dev/null +++ b/server/internal/controller/pano/pano_v1_add_pano.go @@ -0,0 +1,45 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package pano + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + "stackChan/internal/model/entity" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/pano/v1" +) + +func (c *ControllerV1) AddPano(ctx context.Context, req *v1.AddPanoReq) (res *v1.AddPanoRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + + if req.Url == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + + Id, err := dao.DevicePano.Ctx(ctx).Data(entity.DevicePano{ + Mac: mac, + PanoUrl: req.Url, + }).InsertAndGetId() + + if err != nil { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + + res = &v1.AddPanoRes{ + Id: Id, + } + + return res, nil +} diff --git a/server/internal/controller/pano/pano_v1_get_pano_list.go b/server/internal/controller/pano/pano_v1_get_pano_list.go new file mode 100644 index 0000000..1bf95ba --- /dev/null +++ b/server/internal/controller/pano/pano_v1_get_pano_list.go @@ -0,0 +1,41 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package pano + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/pano/v1" +) + +func (c *ControllerV1) GetPanoList(ctx context.Context, req *v1.GetPanoListReq) (res *v1.GetPanoListRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + + var list []model.Pano + + err = dao.DevicePano.Ctx(ctx).Where("mac = ?", mac).Scan(&list) + + if err != nil { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + + if list == nil { + list = make([]model.Pano, 0) + } + + response := v1.GetPanoListRes(list) + + return &response, nil +} diff --git a/server/internal/controller/post/post_v1_create_post.go b/server/internal/controller/post/post_v1_create_post.go index 2365178..a45ae45 100644 --- a/server/internal/controller/post/post_v1_create_post.go +++ b/server/internal/controller/post/post_v1_create_post.go @@ -8,16 +8,22 @@ package post import ( "context" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/internal/model/do" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "stackChan/api/post/v1" ) func (c *ControllerV1) CreatePost(ctx context.Context, req *v1.CreatePostReq) (res *v1.CreatePostRes, err error) { - device, err := dao.Device.Ctx(ctx).Where("mac", req.Mac).One() + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + device, err := dao.Device.Ctx(ctx).Where("mac", mac).One() if err != nil { return nil, err } @@ -25,7 +31,7 @@ func (c *ControllerV1) CreatePost(ctx context.Context, req *v1.CreatePostReq) (r return nil, gerror.NewCode(gcode.CodeInvalidRequest, "The device does not exist or the Mac address is incorrect") } insertId, err := dao.DevicePost.Ctx(ctx).Data(do.DevicePost{ - Mac: req.Mac, + Mac: mac, ContentText: req.ContentText, ContentImage: req.ContentImage, }).InsertAndGetId() diff --git a/server/internal/controller/post/post_v1_create_post_comment.go b/server/internal/controller/post/post_v1_create_post_comment.go index 42e1c72..5ff5ea3 100644 --- a/server/internal/controller/post/post_v1_create_post_comment.go +++ b/server/internal/controller/post/post_v1_create_post_comment.go @@ -8,15 +8,24 @@ package post import ( "context" "stackChan/internal/dao" + "stackChan/internal/model" "stackChan/internal/model/do" "stackChan/api/post/v1" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) CreatePostComment(ctx context.Context, req *v1.CreatePostCommentReq) (res *v1.CreatePostCommentRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } id, err := dao.DevicePostComment.Ctx(ctx).Data(do.DevicePostComment{ PostId: req.PostId, - Mac: req.Mac, + Mac: mac, Content: req.Content, }).InsertAndGetId() if err != nil { diff --git a/server/internal/controller/post/post_v1_delete_post_comment.go b/server/internal/controller/post/post_v1_delete_post_comment.go index 395c4f8..115110c 100644 --- a/server/internal/controller/post/post_v1_delete_post_comment.go +++ b/server/internal/controller/post/post_v1_delete_post_comment.go @@ -12,11 +12,19 @@ import ( "stackChan/internal/model" "stackChan/api/post/v1" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" ) func (c *ControllerV1) DeletePostComment(ctx context.Context, req *v1.DeletePostCommentReq) (res *v1.DeletePostCommentRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } var postComment model.PostComment - err = dao.DevicePostComment.Ctx(ctx).Where("id=?", req.Id).Scan(&postComment) + err = dao.DevicePostComment.Ctx(ctx).Where("id=? AND post_id=? AND mac=?", req.CommentId, req.PostId, mac).Scan(&postComment) if err != nil { return nil, err @@ -26,13 +34,13 @@ func (c *ControllerV1) DeletePostComment(ctx context.Context, req *v1.DeletePost return nil, errors.New("post not found") } - if postComment.Mac != req.Mac { + if postComment.Mac != mac { return nil, errors.New("no authority to delete") } _, err = dao.DevicePostComment. Ctx(ctx). - Where("id = ? AND mac = ?", req.Id, req.Mac). + Where("id=? AND post_id=?", req.CommentId, req.PostId). Delete() if err != nil { return nil, err diff --git a/server/internal/controller/post/post_v1_get_post_comment.go b/server/internal/controller/post/post_v1_get_post_comment.go index 69a4eda..f1cd9a4 100644 --- a/server/internal/controller/post/post_v1_get_post_comment.go +++ b/server/internal/controller/post/post_v1_get_post_comment.go @@ -26,7 +26,7 @@ func (c *ControllerV1) GetPostComment(ctx context.Context, req *v1.GetPostCommen var list []*model.PostComment - db := dao.DevicePostComment.Ctx(ctx).As("dp").Where("mac = ? AND post_id = ?", req.Mac, req.PostId) + db := dao.DevicePostComment.Ctx(ctx).As("dp").Where("post_id = ?", req.PostId) total, err := db.Count() if err != nil { diff --git a/server/internal/controller/stackchandevice/stackchandevice.go b/server/internal/controller/stackchandevice/stackchandevice.go new file mode 100644 index 0000000..27d112b --- /dev/null +++ b/server/internal/controller/stackchandevice/stackchandevice.go @@ -0,0 +1,10 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package stackchandevice diff --git a/server/internal/controller/stackchandevice/stackchandevice_new.go b/server/internal/controller/stackchandevice/stackchandevice_new.go new file mode 100644 index 0000000..c3ab70c --- /dev/null +++ b/server/internal/controller/stackchandevice/stackchandevice_new.go @@ -0,0 +1,20 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package stackchandevice + +import ( + "stackChan/api/stackchandevice" +) + +type ControllerV2 struct{} + +func NewV2() stackchandevice.IStackchandeviceV2 { + return &ControllerV2{} +} diff --git a/server/internal/controller/stackchandevice/stackchandevice_v2_get_device_user_info.go b/server/internal/controller/stackchandevice/stackchandevice_v2_get_device_user_info.go new file mode 100644 index 0000000..e98296d --- /dev/null +++ b/server/internal/controller/stackchandevice/stackchandevice_v2_get_device_user_info.go @@ -0,0 +1,62 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package stackchandevice + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + "stackChan/internal/model/entity" + "stackChan/internal/service" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/stackchandevice/v2" +) + +// GetDeviceUserInfo Get user information corresponding to the device +func (c *ControllerV2) GetDeviceUserInfo(ctx context.Context, req *v2.GetDeviceUserInfoReq) (res *v2.GetDeviceUserInfoRes, err error) { + // 1. Get MAC address from context + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCodef(gcode.CodeInvalidParameter, "Device MAC address is empty") + } + + // 2. Ensure MAC record exists + _, err = service.CreateMacIfNotExists(ctx, mac) + if err != nil { + return nil, gerror.WrapCode(gcode.CodeInternalError, err, "create mac record failed") + } + + // 3. Query device information based on MAC + var device entity.Device + err = dao.Device.Ctx(ctx).Where("mac", mac).Scan(&device) + if err != nil { + return nil, gerror.WrapCodef(gcode.CodeInternalError, err, "Failed to query device information") + } + + // 4. Device not bound to user -> return null + if device.Uid == 0 { + return new(v2.GetDeviceUserInfoRes(nil)), nil + } + + // 5. Query user information based on UID + var user model.User + err = dao.User.Ctx(ctx).Where("uid", device.Uid).Scan(&user) + if err != nil { + return nil, gerror.WrapCode(gcode.CodeInternalError, err, "query user info failed") + } + + // 6. User does not exist -> return null + if user.Uid == 0 { + return new(v2.GetDeviceUserInfoRes(nil)), nil + } + + // 7. Return username normally + return new(v2.GetDeviceUserInfoRes(&user)), nil +} diff --git a/server/internal/controller/stackchandevice/stackchandevice_v2_unbind_device.go b/server/internal/controller/stackchandevice/stackchandevice_v2_unbind_device.go new file mode 100644 index 0000000..b6fc4e2 --- /dev/null +++ b/server/internal/controller/stackchandevice/stackchandevice_v2_unbind_device.go @@ -0,0 +1,60 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package stackchandevice + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + "stackChan/internal/service" + "stackChan/internal/xiaozhi" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/stackchandevice/v2" +) + +// UnbindDevice Unbind device from StackChan side +func (c *ControllerV2) UnbindDevice(ctx context.Context, req *v2.UnbindDeviceReq) (res *v2.UnbindDeviceRes, err error) { + mac := g.RequestFromCtx(ctx).GetCtxVar(model.Mac).String() + if mac == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter) + } + _, err = service.CreateMacIfNotExists(ctx, mac) + if err != nil { + return nil, gerror.NewCode(gcode.CodeInternalError) + } + restoreResponse, err := service.RestoreDefaultAgent(mac) + if err != nil { + return nil, err + } + if !restoreResponse { + return nil, gerror.NewCode(gcode.CodeInternalError, "restore default agent failed") + } + + /// xiaozhi Unbind Device + unbindResponse, err := xiaozhi.UnbindDevice(&mac) + if err != nil { + return nil, gerror.NewCode(gcode.CodeInternalError) + } + if !unbindResponse { + g.Log().Error(ctx, "xiaozhi Unbind Device failed") + return nil, gerror.NewCode(gcode.CodeInternalError) + } + + /// update device table + _, err = dao.Device.Ctx(ctx). + Where("mac", mac). + Data("uid", nil, "bind_time", nil). + Update() + if err != nil { + return nil, gerror.NewCodef(gcode.CodeInternalError, "device unbind failed: %v", err) + } + + return new(v2.UnbindDeviceRes(true)), nil +} diff --git a/server/internal/controller/user/user.go b/server/internal/controller/user/user.go new file mode 100644 index 0000000..8c0fc88 --- /dev/null +++ b/server/internal/controller/user/user.go @@ -0,0 +1,10 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package user diff --git a/server/internal/controller/user/user_new.go b/server/internal/controller/user/user_new.go new file mode 100644 index 0000000..8b9892e --- /dev/null +++ b/server/internal/controller/user/user_new.go @@ -0,0 +1,20 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package user + +import ( + "stackChan/api/user" +) + +type ControllerV2 struct{} + +func NewV2() user.IUserV2 { + return &ControllerV2{} +} diff --git a/server/internal/controller/user/user_v2_get_user_info.go b/server/internal/controller/user/user_v2_get_user_info.go new file mode 100644 index 0000000..0107db6 --- /dev/null +++ b/server/internal/controller/user/user_v2_get_user_info.go @@ -0,0 +1,34 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package user + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/user/v2" +) + +func (c *ControllerV2) GetUserInfo(ctx context.Context, req *v2.GetUserInfoReq) (res *v2.GetUserInfoRes, err error) { + uid := g.RequestFromCtx(ctx).GetCtxVar(model.Uid).Int64() + if uid == 0 { + return nil, gerror.NewCode(gcode.CodeMissingParameter, "user UID is required") + } + var userInfo model.User + err = dao.User.Ctx(ctx).Where("uid=?", uid).Scan(&userInfo) + if err != nil { + return nil, gerror.WrapCode(gcode.CodeDbOperationError, err, "failed to query user information") + } + if userInfo.Uid == 0 { + return nil, gerror.NewCode(gcode.CodeNotFound, "user does not exist") + } + return new(v2.GetUserInfoRes(userInfo)), nil +} diff --git a/server/internal/controller/user/user_v2_login.go b/server/internal/controller/user/user_v2_login.go new file mode 100644 index 0000000..264c737 --- /dev/null +++ b/server/internal/controller/user/user_v2_login.go @@ -0,0 +1,17 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package user + +import ( + "context" + "stackChan/internal/service" + + "stackChan/api/user/v2" +) + +func (c *ControllerV2) Login(ctx context.Context, req *v2.LoginReq) (res *v2.LoginRes, err error) { + return service.Login(ctx, req) +} diff --git a/server/internal/controller/user/user_v2_registration.go b/server/internal/controller/user/user_v2_registration.go new file mode 100644 index 0000000..e462324 --- /dev/null +++ b/server/internal/controller/user/user_v2_registration.go @@ -0,0 +1,17 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package user + +import ( + "context" + "stackChan/internal/service" + + "stackChan/api/user/v2" +) + +func (c *ControllerV2) Registration(ctx context.Context, req *v2.RegistrationReq) (res *v2.RegistrationRes, err error) { + return service.Registration(ctx, req) +} diff --git a/server/internal/controller/xiaozhi/xiaozhi.go b/server/internal/controller/xiaozhi/xiaozhi.go new file mode 100644 index 0000000..e03a498 --- /dev/null +++ b/server/internal/controller/xiaozhi/xiaozhi.go @@ -0,0 +1,10 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package xiaozhi diff --git a/server/internal/controller/xiaozhi/xiaozhi_new.go b/server/internal/controller/xiaozhi/xiaozhi_new.go new file mode 100644 index 0000000..f3ee25b --- /dev/null +++ b/server/internal/controller/xiaozhi/xiaozhi_new.go @@ -0,0 +1,20 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish. +// ================================================================================= + +package xiaozhi + +import ( + "stackChan/api/xiaozhi" +) + +type ControllerV1 struct{} + +func NewV1() xiaozhi.IXiaozhiV1 { + return &ControllerV1{} +} diff --git a/server/internal/controller/xiaozhi/xiaozhi_v1_get_xiao_zhi_generate_license_token.go b/server/internal/controller/xiaozhi/xiaozhi_v1_get_xiao_zhi_generate_license_token.go new file mode 100644 index 0000000..0454529 --- /dev/null +++ b/server/internal/controller/xiaozhi/xiaozhi_v1_get_xiao_zhi_generate_license_token.go @@ -0,0 +1,19 @@ +package xiaozhi + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "stackChan/api/xiaozhi/v1" +) + +func (c *ControllerV1) GetXiaoZhiGenerateLicenseToken(ctx context.Context, req *v1.GetXiaoZhiGenerateLicenseTokenReq) (res *v1.GetXiaoZhiGenerateLicenseTokenRes, err error) { + generateLicenseToken := g.Cfg().MustGet(ctx, "xiaozhi.generate_license_token").String() + if generateLicenseToken == "" { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "generate_license_token is empty") + } + return new(v1.GetXiaoZhiGenerateLicenseTokenRes(generateLicenseToken)), nil +} diff --git a/server/internal/controller/xiaozhi/xiaozhi_v1_get_xiao_zhi_token.go b/server/internal/controller/xiaozhi/xiaozhi_v1_get_xiao_zhi_token.go new file mode 100644 index 0000000..dae57c6 --- /dev/null +++ b/server/internal/controller/xiaozhi/xiaozhi_v1_get_xiao_zhi_token.go @@ -0,0 +1,21 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package xiaozhi + +import ( + "context" + "stackChan/internal/xiaozhi" + + "stackChan/api/xiaozhi/v1" +) + +func (c *ControllerV1) GetXiaoZhiToken(ctx context.Context, req *v1.GetXiaoZhiTokenReq) (res *v1.GetXiaoZhiTokenRes, err error) { + token, err := xiaozhi.GetToken() + if err != nil { + return nil, err + } + return new(v1.GetXiaoZhiTokenRes(token)), nil +} diff --git a/server/internal/controller/xiaozhi/xiaozhi_v1_refresh_token.go b/server/internal/controller/xiaozhi/xiaozhi_v1_refresh_token.go new file mode 100644 index 0000000..4ac19d1 --- /dev/null +++ b/server/internal/controller/xiaozhi/xiaozhi_v1_refresh_token.go @@ -0,0 +1,21 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package xiaozhi + +import ( + "context" + "stackChan/internal/xiaozhi" + + "stackChan/api/xiaozhi/v1" +) + +func (c *ControllerV1) RefreshToken(ctx context.Context, req *v1.RefreshTokenReq) (res *v1.RefreshTokenRes, err error) { + token, err := xiaozhi.GetNewToken() + if err != nil { + return nil, err + } + return new(v1.RefreshTokenRes(token)), nil +} diff --git a/server/internal/dao/app_store.go b/server/internal/dao/app_store.go new file mode 100644 index 0000000..9a9d3a6 --- /dev/null +++ b/server/internal/dao/app_store.go @@ -0,0 +1,27 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "stackChan/internal/dao/internal" +) + +// appStoreDao is the data access object for the table app_store. +// You can define custom methods on it to extend its functionality as needed. +type appStoreDao struct { + *internal.AppStoreDao +} + +var ( + // AppStore is a globally accessible object for table app_store operations. + AppStore = appStoreDao{internal.NewAppStoreDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/device_pano.go b/server/internal/dao/device_pano.go new file mode 100644 index 0000000..ae63e17 --- /dev/null +++ b/server/internal/dao/device_pano.go @@ -0,0 +1,27 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "stackChan/internal/dao/internal" +) + +// devicePanoDao is the data access object for the table device_pano. +// You can define custom methods on it to extend its functionality as needed. +type devicePanoDao struct { + *internal.DevicePanoDao +} + +var ( + // DevicePano is a globally accessible object for table device_pano operations. + DevicePano = devicePanoDao{internal.NewDevicePanoDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/internal/app_store.go b/server/internal/dao/internal/app_store.go new file mode 100644 index 0000000..35bd8d0 --- /dev/null +++ b/server/internal/dao/internal/app_store.go @@ -0,0 +1,98 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// AppStoreDao is the data access object for the table app_store. +type AppStoreDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns AppStoreColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// AppStoreColumns defines and stores column names for the table app_store. +type AppStoreColumns struct { + Id string // + AppName string // App name + AppIconUrl string // App icon URL + Description string // App description + FirmwareUrl string // Firmware / installation package download URL + CreateAt string // Creation time + UpdateAt string // Update time + IsDeleted string // Is deleted, 0 normal 1 deleted +} + +// appStoreColumns holds the columns for the table app_store. +var appStoreColumns = AppStoreColumns{ + Id: "id", + AppName: "app_name", + AppIconUrl: "app_icon_url", + Description: "description", + FirmwareUrl: "firmware_url", + CreateAt: "create_at", + UpdateAt: "update_at", + IsDeleted: "is_deleted", +} + +// NewAppStoreDao creates and returns a new DAO object for table data access. +func NewAppStoreDao(handlers ...gdb.ModelHandler) *AppStoreDao { + return &AppStoreDao{ + group: "default", + table: "app_store", + columns: appStoreColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *AppStoreDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *AppStoreDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *AppStoreDao) Columns() AppStoreColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *AppStoreDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *AppStoreDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *AppStoreDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/internal/device.go b/server/internal/dao/internal/device.go index f0b64c2..3bf337c 100644 --- a/server/internal/dao/internal/device.go +++ b/server/internal/dao/internal/device.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ========================================================================== // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ========================================================================== @@ -21,14 +26,18 @@ type DeviceDao struct { // DeviceColumns defines and stores column names for the table device. type DeviceColumns struct { - Mac string // - Name string // + Mac string // + Name string // + Uid string // Bound user UID + BindTime string // Device binding time } // deviceColumns holds the columns for the table device. var deviceColumns = DeviceColumns{ - Mac: "mac", - Name: "name", + Mac: "mac", + Name: "name", + Uid: "uid", + BindTime: "bind_time", } // NewDeviceDao creates and returns a new DAO object for table data access. diff --git a/server/internal/dao/internal/device_dance.go b/server/internal/dao/internal/device_dance.go index af52a5d..6bf2c0e 100644 --- a/server/internal/dao/internal/device_dance.go +++ b/server/internal/dao/internal/device_dance.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ========================================================================== // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ========================================================================== @@ -21,22 +26,24 @@ type DeviceDanceDao struct { // DeviceDanceColumns defines and stores column names for the table device_dance. type DeviceDanceColumns struct { - Id string // - Mac string // 设备MAC地址 - DanceIndex string // 舞蹈编号,初始为1~3,可扩展 - DanceData string // MotionData - CreatedAt string // - UpdatedAt string // + Id string // + Mac string // Device MAC address + DanceName string // Dance name + DanceData string // MotionData + MusicUrl string // Dance background music URL + CreatedAt string // + UpdatedAt string // } // deviceDanceColumns holds the columns for the table device_dance. var deviceDanceColumns = DeviceDanceColumns{ - Id: "id", - Mac: "mac", - DanceIndex: "dance_index", - DanceData: "dance_data", - CreatedAt: "created_at", - UpdatedAt: "updated_at", + Id: "id", + Mac: "mac", + DanceName: "dance_name", + DanceData: "dance_data", + MusicUrl: "music_url", + CreatedAt: "created_at", + UpdatedAt: "updated_at", } // NewDeviceDanceDao creates and returns a new DAO object for table data access. diff --git a/server/internal/dao/internal/device_friend.go b/server/internal/dao/internal/device_friend.go index 292a878..77ed67e 100644 --- a/server/internal/dao/internal/device_friend.go +++ b/server/internal/dao/internal/device_friend.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ========================================================================== // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ========================================================================== diff --git a/server/internal/dao/internal/device_pano.go b/server/internal/dao/internal/device_pano.go new file mode 100644 index 0000000..ccb9f1e --- /dev/null +++ b/server/internal/dao/internal/device_pano.go @@ -0,0 +1,92 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// DevicePanoDao is the data access object for the table device_pano. +type DevicePanoDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns DevicePanoColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// DevicePanoColumns defines and stores column names for the table device_pano. +type DevicePanoColumns struct { + Id string // + Mac string // Device MAC address + PanoUrl string // Panorama URL + CreatedAt string // Creation time + UpdatedAt string // +} + +// devicePanoColumns holds the columns for the table device_pano. +var devicePanoColumns = DevicePanoColumns{ + Id: "id", + Mac: "mac", + PanoUrl: "pano_url", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +// NewDevicePanoDao creates and returns a new DAO object for table data access. +func NewDevicePanoDao(handlers ...gdb.ModelHandler) *DevicePanoDao { + return &DevicePanoDao{ + group: "default", + table: "device_pano", + columns: devicePanoColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *DevicePanoDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *DevicePanoDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *DevicePanoDao) Columns() DevicePanoColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *DevicePanoDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *DevicePanoDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *DevicePanoDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/internal/device_post.go b/server/internal/dao/internal/device_post.go index cc6b587..e93755f 100644 --- a/server/internal/dao/internal/device_post.go +++ b/server/internal/dao/internal/device_post.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ========================================================================== // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ========================================================================== @@ -22,10 +27,10 @@ type DevicePostDao struct { // DevicePostColumns defines and stores column names for the table device_post. type DevicePostColumns struct { Id string // - Mac string // 发帖设备MAC - ContentText string // 文本内容 - ContentImage string // 图片URL - CreatedAt string // 发帖时间 + Mac string // Post device MAC + ContentText string // + ContentImage string // Image URL + CreatedAt string // Post time } // devicePostColumns holds the columns for the table device_post. diff --git a/server/internal/dao/internal/device_post_comment.go b/server/internal/dao/internal/device_post_comment.go index 04f69d7..95b3af6 100644 --- a/server/internal/dao/internal/device_post_comment.go +++ b/server/internal/dao/internal/device_post_comment.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ========================================================================== // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ========================================================================== @@ -22,10 +27,10 @@ type DevicePostCommentDao struct { // DevicePostCommentColumns defines and stores column names for the table device_post_comment. type DevicePostCommentColumns struct { Id string // - PostId string // 帖子ID - Mac string // 评论设备MAC - Content string // 评论内容 - CreatedAt string // 评论时间 + PostId string // Post ID + Mac string // Comment device MAC + Content string // + CreatedAt string // Comment time } // devicePostCommentColumns holds the columns for the table device_post_comment. diff --git a/server/internal/dao/internal/sqlite_sequence.go b/server/internal/dao/internal/sqlite_sequence.go index 6b5a6ad..2eb4135 100644 --- a/server/internal/dao/internal/sqlite_sequence.go +++ b/server/internal/dao/internal/sqlite_sequence.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ========================================================================== // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ========================================================================== diff --git a/server/internal/dao/internal/user.go b/server/internal/dao/internal/user.go new file mode 100644 index 0000000..f3b3fc8 --- /dev/null +++ b/server/internal/dao/internal/user.go @@ -0,0 +1,108 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// UserDao is the data access object for the table user. +type UserDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns UserColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// UserColumns defines and stores column names for the table user. +type UserColumns struct { + Uid string // User unique UID (remote platform primary key) + Username string // Login username + Userslug string // User alias + DisplayName string // User display name + IconText string // User icon text + IconBgColor string // Icon background color + EmailConfirmed string // Email verified, 0-no 1-yes + JoinDate string // Registration timestamp (milliseconds) + LastOnline string // Last online timestamp (milliseconds) + UserStatus string // User online status + CreateAt string // Local creation time + UpdateAt string // Local update time + IsDeleted string // Is deleted, 0-normal 1-deleted +} + +// userColumns holds the columns for the table user. +var userColumns = UserColumns{ + Uid: "uid", + Username: "username", + Userslug: "userslug", + DisplayName: "display_name", + IconText: "icon_text", + IconBgColor: "icon_bg_color", + EmailConfirmed: "email_confirmed", + JoinDate: "join_date", + LastOnline: "last_online", + UserStatus: "user_status", + CreateAt: "create_at", + UpdateAt: "update_at", + IsDeleted: "is_deleted", +} + +// NewUserDao creates and returns a new DAO object for table data access. +func NewUserDao(handlers ...gdb.ModelHandler) *UserDao { + return &UserDao{ + group: "default", + table: "user", + columns: userColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *UserDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *UserDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *UserDao) Columns() UserColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *UserDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *UserDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *UserDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/user.go b/server/internal/dao/user.go new file mode 100644 index 0000000..5ce8a36 --- /dev/null +++ b/server/internal/dao/user.go @@ -0,0 +1,27 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "stackChan/internal/dao/internal" +) + +// userDao is the data access object for the table user. +// You can define custom methods on it to extend its functionality as needed. +type userDao struct { + *internal.UserDao +} + +var ( + // User is a globally accessible object for table user operations. + User = userDao{internal.NewUserDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/middleware/middleware.go b/server/internal/middleware/middleware.go new file mode 100644 index 0000000..e560463 --- /dev/null +++ b/server/internal/middleware/middleware.go @@ -0,0 +1,113 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package middleware + +import ( + "stackChan/internal/model" + "stackChan/internal/service" + "stackChan/internal/web_socket" + "strings" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/golang-jwt/jwt/v5" +) + +// TokenAuthMiddleware token +func TokenAuthMiddleware(r *ghttp.Request) { + mac, err := web_socket.GetMac(r) + if err != nil { + r.Middleware.Next() + return + } + if mac != "" { + r.SetCtxVar(model.Mac, mac) + } + r.Middleware.Next() +} + +func V2TokenAuthMiddleware(r *ghttp.Request) { + if strings.HasPrefix(r.URL.Path, "/stackChan/v2/user/login") || strings.HasPrefix(r.URL.Path, "/stackChan/v2/user/registration") { + r.Middleware.Next() + return + } + tokenString := r.Header.Get("token") + if tokenString == "" { + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeNotAuthorized, "The token cannot be empty.")) + } + tokenString = strings.TrimPrefix(tokenString, "Bearer ") + tokenString = strings.TrimSpace(tokenString) + if tokenString == "" { + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeNotAuthorized, "Invalid token format")) + } + jwtSecret := service.GetJwtSecret() + if jwtSecret == "" { + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeInternalError, "jwt The secret has not been configured.")) + } + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, gerror.NewCodef(gcode.CodeNotAuthorized, "token signing algorithm error: %v, %v", token.Header["alg"]) + } + return []byte(jwtSecret), nil + }) + if err != nil || !token.Valid { + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeNotAuthorized, "The token is invalid or has expired.")) + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeNotAuthorized, "Token payload format is incorrect")) + } + uid, ok := claims["id"].(float64) + if !ok { + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeNotAuthorized, "The user ID in the token is invalid.")) + } + r.SetCtxVar(model.Uid, int64(uid)) + r.Middleware.Next() +} + +// CORS allow cross-origin +func CORS(r *ghttp.Request) { + r.Response.CORSDefault() + r.Middleware.Next() +} + +// AdminTokenAuthMiddleware Admin token validation +func AdminTokenAuthMiddleware(r *ghttp.Request) { + if strings.HasPrefix(r.URL.Path, "/admin/stackChan/login") { + r.Middleware.Next() + return + } + + tokenString := r.Header.Get("Authorization") + if tokenString == "" { + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeNotAuthorized, "Token missing")) + return + } + + jwtSecret := service.GetJwtSecret() + if jwtSecret == "" { + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeInternalError, "JWT secret has not been configured.")) + return + } + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + if err != nil || !token.Valid { + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeNotAuthorized, "The token is invalid.")) + return + } + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + if Username, ok := claims[model.Username].(string); ok { + if Username != "" { + r.SetCtxVar(model.Username, Username) + r.Middleware.Next() + return + } + } + } + r.Response.WriteJsonExit(gerror.NewCode(gcode.CodeNotAuthorized, "The username is missing in the token.")) +} diff --git a/server/internal/model/app_store.go b/server/internal/model/app_store.go new file mode 100644 index 0000000..20b16dc --- /dev/null +++ b/server/internal/model/app_store.go @@ -0,0 +1,18 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +import "github.com/gogf/gf/v2/os/gtime" + +type AppInfo struct { + Id int64 `json:"id" orm:"id" description:""` // App ID + AppName string `json:"appName" orm:"app_name" description:"App name"` // App name + AppIconUrl string `json:"appIconUrl" orm:"app_icon_url" description:"App icon URL"` // App icon URL + Description string `json:"description" orm:"description" description:"App description"` // App description + FirmwareUrl string `json:"firmwareUrl" orm:"firmware_url" description:"Firmware / installation package download URL"` // Firmware / installation package download URL + CreateAt *gtime.Time `json:"createAt" orm:"create_at" description:"Creation time"` // Creation time + UpdateAt *gtime.Time `json:"updateAt" orm:"update_at" description:"Update time"` // Update time +} diff --git a/server/internal/model/dance.go b/server/internal/model/dance.go new file mode 100644 index 0000000..911c091 --- /dev/null +++ b/server/internal/model/dance.go @@ -0,0 +1,15 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +import "encoding/json" + +type Dance struct { + Id int64 `json:"id" orm:"id" description:"Dance ID"` // + DanceName string `json:"danceName" orm:"dance_name" description:"Dance name"` // Dance name + MusicUrl string `json:"musicUrl" orm:"music_url" description:"Dance background music URL"` // Dance background music URL + DanceData json.RawMessage `json:"danceData" orm:"dance_data" description:"MotionData"` // Dance motion data +} diff --git a/server/internal/model/device.go b/server/internal/model/device.go index b8d7d0a..79a9f1c 100644 --- a/server/internal/model/device.go +++ b/server/internal/model/device.go @@ -6,6 +6,25 @@ SPDX-License-Identifier: MIT package model type DeviceInfo struct { - Mac string `json:"mac" v:"required" description:"Mac address"` - Name string `json:"name" v:"required" description:"Name"` + Mac string `json:"mac" v:"required" description:"Mac address"` + Name string `json:"name" v:"required" description:"Name"` + Uid int64 `json:"uid" orm:"uid" description:"Bound user UID"` // Bound user UID + BindTime string `json:"bind_time" orm:"bind_time" description:"Device binding time"` // Device binding time +} + +type IPLocation struct { + Status string `json:"status"` + Country string `json:"country"` + CountryCode string `json:"countryCode"` + Region string `json:"region"` + RegionName string `json:"regionName"` + City string `json:"city"` + Zip string `json:"zip"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + TimeZone string `json:"timezone"` + Isp string `json:"isp"` + Org string `json:"org"` + As string `json:"as"` + Query string `json:"query"` } diff --git a/server/internal/model/do/app_store.go b/server/internal/model/do/app_store.go new file mode 100644 index 0000000..29d52c9 --- /dev/null +++ b/server/internal/model/do/app_store.go @@ -0,0 +1,28 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// AppStore is the golang structure of table app_store for DAO operations like Where/Data. +type AppStore struct { + g.Meta `orm:"table:app_store, do:true"` + Id any // + AppName any // App name + AppIconUrl any // App icon URL + Description any // App description + FirmwareUrl any // Firmware / installation package download URL + CreateAt *gtime.Time // Creation time + UpdateAt *gtime.Time // Update time + IsDeleted any // Is deleted, 0 normal 1 deleted +} diff --git a/server/internal/model/do/device.go b/server/internal/model/do/device.go index 9dedaf7..7569b88 100644 --- a/server/internal/model/do/device.go +++ b/server/internal/model/do/device.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= @@ -10,7 +15,9 @@ import ( // Device is the golang structure of table device for DAO operations like Where/Data. type Device struct { - g.Meta `orm:"table:device, do:true"` - Mac any // - Name any // + g.Meta `orm:"table:device, do:true"` + Mac any // + Name any // + Uid any // Bound user UID + BindTime any // Device binding time } diff --git a/server/internal/model/do/device_dance.go b/server/internal/model/do/device_dance.go index 0505e36..b236892 100644 --- a/server/internal/model/do/device_dance.go +++ b/server/internal/model/do/device_dance.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= @@ -11,11 +16,12 @@ import ( // DeviceDance is the golang structure of table device_dance for DAO operations like Where/Data. type DeviceDance struct { - g.Meta `orm:"table:device_dance, do:true"` - Id any // - Mac any // 设备MAC地址 - DanceIndex any // 舞蹈编号,初始为1~3,可扩展 - DanceData any // MotionData - CreatedAt *gtime.Time // - UpdatedAt *gtime.Time // + g.Meta `orm:"table:device_dance, do:true"` + Id any // + Mac any // Device MAC address + DanceName any // Dance name + DanceData any // MotionData + MusicUrl any // Dance background music URL + CreatedAt *gtime.Time // + UpdatedAt *gtime.Time // } diff --git a/server/internal/model/do/device_friend.go b/server/internal/model/do/device_friend.go index e11c509..d89cfb9 100644 --- a/server/internal/model/do/device_friend.go +++ b/server/internal/model/do/device_friend.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= diff --git a/server/internal/model/do/device_pano.go b/server/internal/model/do/device_pano.go new file mode 100644 index 0000000..04f12ad --- /dev/null +++ b/server/internal/model/do/device_pano.go @@ -0,0 +1,25 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// DevicePano is the golang structure of table device_pano for DAO operations like Where/Data. +type DevicePano struct { + g.Meta `orm:"table:device_pano, do:true"` + Id any // + Mac any // Device MAC address + PanoUrl any // Panorama URL + CreatedAt *gtime.Time // Creation time + UpdatedAt *gtime.Time // +} diff --git a/server/internal/model/do/device_post.go b/server/internal/model/do/device_post.go index 7a21a68..0a19ee3 100644 --- a/server/internal/model/do/device_post.go +++ b/server/internal/model/do/device_post.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= @@ -13,8 +18,8 @@ import ( type DevicePost struct { g.Meta `orm:"table:device_post, do:true"` Id any // - Mac any // 发帖设备MAC - ContentText any // 文本内容 - ContentImage any // 图片URL - CreatedAt *gtime.Time // 发帖时间 + Mac any // Post device MAC + ContentText any // + ContentImage any // Image URL + CreatedAt *gtime.Time // Post time } diff --git a/server/internal/model/do/device_post_comment.go b/server/internal/model/do/device_post_comment.go index 1e0d6ca..ba98da3 100644 --- a/server/internal/model/do/device_post_comment.go +++ b/server/internal/model/do/device_post_comment.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= @@ -13,8 +18,8 @@ import ( type DevicePostComment struct { g.Meta `orm:"table:device_post_comment, do:true"` Id any // - PostId any // 帖子ID - Mac any // 评论设备MAC - Content any // 评论内容 - CreatedAt *gtime.Time // 评论时间 + PostId any // Post ID + Mac any // Comment device MAC + Content any // + CreatedAt *gtime.Time // Comment time } diff --git a/server/internal/model/do/user.go b/server/internal/model/do/user.go new file mode 100644 index 0000000..c2f8799 --- /dev/null +++ b/server/internal/model/do/user.go @@ -0,0 +1,33 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// User is the golang structure of table user for DAO operations like Where/Data. +type User struct { + g.Meta `orm:"table:user, do:true"` + Uid any // User unique UID (remote platform primary key) + Username any // Login username + Userslug any // User alias + DisplayName any // User display name + IconText any // User icon text + IconBgColor any // Icon background color + EmailConfirmed any // Email verified, 0-no 1-yes + JoinDate any // Registration timestamp (milliseconds) + LastOnline any // Last online timestamp (milliseconds) + UserStatus any // User online status + CreateAt *gtime.Time // Local creation time + UpdateAt *gtime.Time // Local update time + IsDeleted any // Is deleted, 0-normal 1-deleted +} diff --git a/server/internal/model/empty.go b/server/internal/model/empty.go new file mode 100644 index 0000000..5a915bb --- /dev/null +++ b/server/internal/model/empty.go @@ -0,0 +1,9 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +type Empty struct { +} diff --git a/server/internal/model/entity/app_store.go b/server/internal/model/entity/app_store.go new file mode 100644 index 0000000..cc8c743 --- /dev/null +++ b/server/internal/model/entity/app_store.go @@ -0,0 +1,26 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// AppStore is the golang structure for table app_store. +type AppStore struct { + Id int64 `json:"id" orm:"id" description:""` // + AppName string `json:"appName" orm:"app_name" description:"App name"` // App name + AppIconUrl string `json:"appIconUrl" orm:"app_icon_url" description:"App icon URL"` // App icon URL + Description string `json:"description" orm:"description" description:"App description"` // App description + FirmwareUrl string `json:"firmwareUrl" orm:"firmware_url" description:"Firmware / installation package download URL"` // Firmware / installation package download URL + CreateAt *gtime.Time `json:"createAt" orm:"create_at" description:"Creation time"` // Creation time + UpdateAt *gtime.Time `json:"updateAt" orm:"update_at" description:"Update time"` // Update time + IsDeleted int `json:"isDeleted" orm:"is_deleted" description:"Is deleted, 0 normal 1 deleted"` // Is deleted, 0 normal 1 deleted +} diff --git a/server/internal/model/entity/device.go b/server/internal/model/entity/device.go index bffc16f..ba838df 100644 --- a/server/internal/model/entity/device.go +++ b/server/internal/model/entity/device.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= @@ -6,6 +11,8 @@ package entity // Device is the golang structure for table device. type Device struct { - Mac string `json:"mac" orm:"mac" description:""` // - Name string `json:"name" orm:"name" description:""` // + Mac string `json:"mac" orm:"mac" description:""` // + Name string `json:"name" orm:"name" description:""` // + Uid int64 `json:"uid" orm:"uid" description:"Bound user UID"` // Bound user UID + BindTime string `json:"bindTime" orm:"bind_time" description:"Device binding time"` // Device binding time } diff --git a/server/internal/model/entity/device_dance.go b/server/internal/model/entity/device_dance.go index 2b01192..2fc64c7 100644 --- a/server/internal/model/entity/device_dance.go +++ b/server/internal/model/entity/device_dance.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= @@ -10,10 +15,11 @@ import ( // DeviceDance is the golang structure for table device_dance. type DeviceDance struct { - Id int64 `json:"id" orm:"id" description:""` // - Mac string `json:"mac" orm:"mac" description:"设备MAC地址"` // 设备MAC地址 - DanceIndex int `json:"danceIndex" orm:"dance_index" description:"舞蹈编号,初始为1~3,可扩展"` // 舞蹈编号,初始为1~3,可扩展 - DanceData string `json:"danceData" orm:"dance_data" description:"MotionData"` // MotionData - CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` // - UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` // + Id int64 `json:"id" orm:"id" description:""` // + Mac string `json:"mac" orm:"mac" description:"Device MAC address"` // Device MAC address + DanceName string `json:"danceName" orm:"dance_name" description:"Dance name"` // Dance name + DanceData string `json:"danceData" orm:"dance_data" description:"MotionData"` // MotionData + MusicUrl string `json:"musicUrl" orm:"music_url" description:"Dance background music URL"` // Dance background music URL + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` // + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` // } diff --git a/server/internal/model/entity/device_friend.go b/server/internal/model/entity/device_friend.go index 367e425..63f4d87 100644 --- a/server/internal/model/entity/device_friend.go +++ b/server/internal/model/entity/device_friend.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= diff --git a/server/internal/model/entity/device_pano.go b/server/internal/model/entity/device_pano.go new file mode 100644 index 0000000..ee63fdc --- /dev/null +++ b/server/internal/model/entity/device_pano.go @@ -0,0 +1,23 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// DevicePano is the golang structure for table device_pano. +type DevicePano struct { + Id int64 `json:"id" orm:"id" description:""` // + Mac string `json:"mac" orm:"mac" description:"Device MAC address"` // Device MAC address + PanoUrl string `json:"panoUrl" orm:"pano_url" description:"Panorama URL"` // Panorama URL + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"Creation time"` // Creation time + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` // +} diff --git a/server/internal/model/entity/device_post.go b/server/internal/model/entity/device_post.go index 12b07cd..11e492e 100644 --- a/server/internal/model/entity/device_post.go +++ b/server/internal/model/entity/device_post.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= @@ -10,9 +15,9 @@ import ( // DevicePost is the golang structure for table device_post. type DevicePost struct { - Id int64 `json:"id" orm:"id" description:""` // - Mac string `json:"mac" orm:"mac" description:"发帖设备MAC"` // 发帖设备MAC - ContentText string `json:"contentText" orm:"content_text" description:"文本内容"` // 文本内容 - ContentImage string `json:"contentImage" orm:"content_image" description:"图片URL"` // 图片URL - CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"发帖时间"` // 发帖时间 + Id int64 `json:"id" orm:"id" description:""` // + Mac string `json:"mac" orm:"mac" description:"Post device MAC"` // Post device MAC + ContentText string `json:"contentText" orm:"content_text" description:""` // + ContentImage string `json:"contentImage" orm:"content_image" description:"Image URL"` // Image URL + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"Post time"` // Post time } diff --git a/server/internal/model/entity/device_post_comment.go b/server/internal/model/entity/device_post_comment.go index 2b4a07c..2a725ae 100644 --- a/server/internal/model/entity/device_post_comment.go +++ b/server/internal/model/entity/device_post_comment.go @@ -1,3 +1,8 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + // ================================================================================= // Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. // ================================================================================= @@ -10,9 +15,9 @@ import ( // DevicePostComment is the golang structure for table device_post_comment. type DevicePostComment struct { - Id int64 `json:"id" orm:"id" description:""` // - PostId int64 `json:"postId" orm:"post_id" description:"帖子ID"` // 帖子ID - Mac string `json:"mac" orm:"mac" description:"评论设备MAC"` // 评论设备MAC - Content string `json:"content" orm:"content" description:"评论内容"` // 评论内容 - CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"评论时间"` // 评论时间 + Id int64 `json:"id" orm:"id" description:""` // + PostId int64 `json:"postId" orm:"post_id" description:"Post ID"` // Post ID + Mac string `json:"mac" orm:"mac" description:"Comment device MAC"` // Comment device MAC + Content string `json:"content" orm:"content" description:""` // + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"Comment time"` // Comment time } diff --git a/server/internal/model/entity/user.go b/server/internal/model/entity/user.go new file mode 100644 index 0000000..de2fc91 --- /dev/null +++ b/server/internal/model/entity/user.go @@ -0,0 +1,31 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// User is the golang structure for table user. +type User struct { + Uid int64 `json:"uid" orm:"uid" description:"User unique UID (remote platform primary key)"` // User unique UID (remote platform primary key) + Username string `json:"username" orm:"username" description:"Login username"` // Login username + Userslug string `json:"userslug" orm:"userslug" description:"User alias"` // User alias + DisplayName string `json:"displayName" orm:"display_name" description:"User display name"` // User display name + IconText string `json:"iconText" orm:"icon_text" description:"User icon text"` // User icon text + IconBgColor string `json:"iconBgColor" orm:"icon_bg_color" description:"Icon background color"` // Icon background color + EmailConfirmed int `json:"emailConfirmed" orm:"email_confirmed" description:"Email verified, 0-no 1-yes"` // Email verified, 0-no 1-yes + JoinDate int64 `json:"joinDate" orm:"join_date" description:"Registration timestamp (milliseconds)"` // Registration timestamp (milliseconds) + LastOnline int64 `json:"lastOnline" orm:"last_online" description:"Last online timestamp (milliseconds)"` // Last online timestamp (milliseconds) + UserStatus string `json:"userStatus" orm:"user_status" description:"User online status"` // User online status + CreateAt *gtime.Time `json:"createAt" orm:"create_at" description:"Local creation time"` // Local creation time + UpdateAt *gtime.Time `json:"updateAt" orm:"update_at" description:"Local update time"` // Local update time + IsDeleted int `json:"isDeleted" orm:"is_deleted" description:"Is deleted, 0-normal 1-deleted"` // Is deleted, 0-normal 1-deleted +} diff --git a/server/internal/model/pano.go b/server/internal/model/pano.go new file mode 100644 index 0000000..458fdc9 --- /dev/null +++ b/server/internal/model/pano.go @@ -0,0 +1,15 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +import "github.com/gogf/gf/v2/os/gtime" + +type Pano struct { + Id int64 `json:"id" orm:"id" description:""` // + PanoUrl string `json:"panoUrl" orm:"pano_url" description:"Panorama URL"` // Panorama URL + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"Creation time"` // Creation time + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` // +} diff --git a/server/internal/model/post.go b/server/internal/model/post.go index 56e6dd1..15115f2 100644 --- a/server/internal/model/post.go +++ b/server/internal/model/post.go @@ -8,13 +8,13 @@ package model import "github.com/gogf/gf/v2/os/gtime" type Post struct { - Id int64 `json:"id" orm:"id" description:"帖子ID"` - Mac string `json:"mac" orm:"mac" description:"发帖设备MAC"` - Name string `json:"name" orm:"name" description:"发帖设备名称"` - ContentText string `json:"contentText" orm:"content_text" description:"文本内容"` - ContentImage string `json:"contentImage" orm:"content_image" description:"图片URL"` - CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"发帖时间"` - PostCommentList []*PostComment `json:"postCommentList" orm:"postCommentList" description:"评论"` + Id int64 `json:"id" orm:"id" description:"Post ID"` + Mac string `json:"mac" orm:"mac" description:"Post device MAC"` + Name string `json:"name" orm:"name" description:"Post device name"` + ContentText string `json:"contentText" orm:"content_text" description:"Text content"` + ContentImage string `json:"contentImage" orm:"content_image" description:"Image URL"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"Post time"` + PostCommentList []*PostComment `json:"postCommentList" orm:"postCommentList" description:"Comments"` } type PostComment struct { diff --git a/server/internal/model/user.go b/server/internal/model/user.go new file mode 100644 index 0000000..c295ef0 --- /dev/null +++ b/server/internal/model/user.go @@ -0,0 +1,73 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +type User struct { + Uid int64 `json:"uid" orm:"uid" description:"User unique UID (remote platform primary key)"` // User unique UID (remote platform primary key) + Username string `json:"username" orm:"username" description:"Login username"` // Login username + Userslug string `json:"userslug" orm:"userslug" description:"User alias"` // User alias + DisplayName string `json:"displayName" orm:"display_name" description:"User display name"` // User display name + IconText string `json:"iconText" orm:"icon_text" description:"User icon text"` // User icon text + IconBgColor string `json:"iconBgColor" orm:"icon_bg_color" description:"Icon background color"` // Icon background color + EmailConfirmed int `json:"emailConfirmed" orm:"email_confirmed" description:"Email verified, 0-no 1-yes"` // Email verified, 0-no 1-yes + JoinDate int64 `json:"joinDate" orm:"join_date" description:"Registration timestamp (milliseconds)"` // Registration timestamp (milliseconds) + LastOnline int64 `json:"lastOnline" orm:"last_online" description:"Last online timestamp (milliseconds)"` // Last online timestamp (milliseconds) + UserStatus string `json:"userStatus" orm:"user_status" description:"User online status"` // User online status +} + +type RemoteLoginResp struct { + Status struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"status"` + Response struct { + Uid int64 `json:"uid"` + Username string `json:"username"` + Userslug string `json:"userslug"` + Picture interface{} `json:"picture"` + Status string `json:"status"` + Postcount int `json:"postcount"` + Reputation int `json:"reputation"` + EmailConfirmed int `json:"email:confirmed"` + Lastonline int64 `json:"lastonline"` + Flags interface{} `json:"flags"` + Banned bool `json:"banned"` + BannedExpire int `json:"banned:expire"` + Joindate int64 `json:"joindate"` + Fullname interface{} `json:"fullname"` + Displayname string `json:"displayname"` + IconText string `json:"icon:text"` + IconBgColor string `json:"icon:bgColor"` + JoindateISO string `json:"joindateISO"` + LastonlineISO string `json:"lastonlineISO"` + BannedUntil int `json:"banned_until"` + BannedReadable string `json:"banned_until_readable"` + } `json:"response"` +} + +type RegistrationResponse struct { + Uid int64 `json:"uid"` + Username string `json:"username"` + Userslug string `json:"userslug"` + Email string `json:"email"` + EmailConfirmed int `json:"email:confirmed"` + JoinDate int64 `json:"joindate"` + LastOnline int64 `json:"lastonline"` + Picture interface{} `json:"picture"` + IconBgColor string `json:"icon:bgColor"` + Fullname interface{} `json:"fullname"` + Displayname string `json:"displayname"` + IconText string `json:"icon:text"` + UserStatus string `json:"status"` // User status: online +} + +type RemoteRegisterResp struct { + Status struct { + Code string `json:"code"` // Status code: ok/bad-request + Message string `json:"message"` // Error message / success message + } `json:"status"` + RegistrationResponse `json:"response"` +} diff --git a/server/internal/model/value_constant.go b/server/internal/model/value_constant.go new file mode 100644 index 0000000..1567d69 --- /dev/null +++ b/server/internal/model/value_constant.go @@ -0,0 +1,16 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +const ( + Authorization = "Authorization" + Mac = "Mac" + Exp = "Exp" + Username = "username" + Uid = "uid" + + DefaultDanceData = "[\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 910\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 910\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 1648\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 835\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 835\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 422\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 422\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 870\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 870\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 925\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 925\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 651\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 760\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 760\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 542\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 542\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 627\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 443\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 443\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1325\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1325\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 989\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 989\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1195\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 1195\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1118\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 1118\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 582\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 582\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 772\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 772\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1056\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 1056\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 557\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 557\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 429\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 429\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 651\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1111\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 1111\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 655\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 655\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 359\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 359\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1062\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 1062\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 634\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 634\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 494\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 494\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1294\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1294\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 555\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 555\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1291\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 1291\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1025\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1025\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1404\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 1404\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 627\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1091\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 1091\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 857\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 857\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 830\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 830\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 463\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 463\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 345\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 345\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1035\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 1035\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1004\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 1004\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1121\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 1121\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1093\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 1093\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 972\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 972\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 618\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 618\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 660\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 660\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 754\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 754\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1301\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 1301\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1341\n },\n \"pitchServo\": {\n \"angle\": 570,\n \"speed\": 1341\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1026\n },\n \"pitchServo\": {\n \"angle\": 330,\n \"speed\": 1026\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1107\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1107\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1084\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1084\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 460\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 460\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1003\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 1003\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1070\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 1070\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 582\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 582\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 349\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1373\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1373\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 518\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 518\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 794\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 794\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 707\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 707\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 610\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 610\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 850\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 850\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1245\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1245\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1364\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1364\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 429\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 429\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 654\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 654\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 874\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 874\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 553\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 553\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 809\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 809\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 647\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 647\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 488\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 488\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1433\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 1433\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 395\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 395\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1408\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 1408\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 650\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 650\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 554\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 554\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 721\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 721\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1154\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1154\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 993\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 993\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1061\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1061\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 674\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 674\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 494\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 494\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 627\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1451\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1451\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1440\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 1440\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 773\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 773\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1349\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1349\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 490\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 490\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1428\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 1428\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 964\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 964\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 538\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 538\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 512\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 512\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1358\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 1358\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1056\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 1056\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1459\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1459\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 530\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 530\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1074\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1074\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1293\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1293\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 651\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 380\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 380\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 888\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 888\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 390\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 390\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1278\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1278\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 826\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 826\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 579\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 579\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1168\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1168\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 915\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 915\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1194\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1194\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 677\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 677\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 651\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 644\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 644\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 576\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 576\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1027\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1027\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1162\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1162\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1065\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1065\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1166\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1166\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 348\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1004\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1004\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1216\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1216\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1104\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1104\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1145\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1145\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 651\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 655\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 655\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1436\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1436\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 301\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1020\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1020\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 651\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1364\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1364\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1071\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1071\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 596\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 596\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 572\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 572\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1347\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 1347\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 817\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 817\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 300\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 300\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 379\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 379\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 910\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 910\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1335\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1335\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 739\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 739\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 791\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 791\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 738\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 738\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 864\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 864\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 935\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 935\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 757\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 757\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1369\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1369\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1366\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1366\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 383\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 383\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 889\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 889\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 410\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 410\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 651\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1409\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1409\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1201\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 1201\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1391\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 1391\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 448\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 448\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 947\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 947\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1458\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1458\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1130\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 1130\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 333\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 333\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 379\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 379\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1160\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1160\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1183\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1183\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1080\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1080\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1426\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1426\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 651\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1090\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1090\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1108\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 1108\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1426\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 1426\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 546\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 546\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1195\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1195\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 443\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 443\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 594\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 594\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1180\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1180\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1320\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1320\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 559\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 559\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 479\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 479\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 994\n },\n \"pitchServo\": {\n \"angle\": 666,\n \"speed\": 994\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 339\n },\n \"pitchServo\": {\n \"angle\": 234,\n \"speed\": 339\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 408\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 408\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 901\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 901\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 972\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 972\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1085\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1085\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1040\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1040\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 528\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 528\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 543\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 543\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1201\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1201\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 1358\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 1358\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 889\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 889\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 980\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 980\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1069\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 1069\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 1365\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1365\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1451\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1451\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 735\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 735\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1177\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1177\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 1034\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1034\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 594\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 594\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 1132\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 1132\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 769\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 769\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 856\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 856\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1298\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1298\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 318\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 318\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 523\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 523\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1408\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1408\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 186\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 537\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 537\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 971\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 971\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 497\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 497\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 301\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 301\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 164\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1051\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1051\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 955\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 955\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1027\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 1027\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 353\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 353\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 348\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1150\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1150\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1005\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1005\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1003\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1003\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 411\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 411\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 887\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 887\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 327\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 327\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1157\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1157\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 849\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 849\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 351\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 351\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 475\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 475\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 716\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 716\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 981\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 981\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 601\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 601\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 958\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 958\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 748\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 748\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1366\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1366\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 373\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 373\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1411\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1411\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 301\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1060\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1060\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 809\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 809\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1070\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1070\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 693\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 693\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 164\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1058\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 1058\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 915\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 915\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1383\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1383\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 933\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 933\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1163\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1163\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 696\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 696\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 513\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 513\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 538\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 538\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1176\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1176\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 907\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 907\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 635\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 635\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 529\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 529\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1057\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1057\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 853\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 853\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 402\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 402\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 529\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 529\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 996\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 996\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 773\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 773\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1107\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1107\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1418\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1418\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 758\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 758\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1307\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1307\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 465\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 465\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1267\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1267\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 884\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 884\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 305\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 305\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1143\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1143\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1041\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1041\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1044\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1044\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 784\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 784\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1488\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1488\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1378\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1378\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1273\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1273\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 872\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 872\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 320\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 320\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1380\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1380\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 553\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 553\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1359\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1359\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 410\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 410\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 751\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 751\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 164\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 745\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 745\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1487\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1487\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 438\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 438\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1191\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1191\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1248\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1248\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 502\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 502\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 429\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 429\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1135\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1135\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1211\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1211\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1090\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1090\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 974\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 974\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 489\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 489\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 820\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 820\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 579\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 579\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 845\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 845\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 725\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 725\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 671\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 671\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1098\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1098\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1283\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1283\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 741\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 741\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1344\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1344\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 514\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 514\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 969\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 969\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 643\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 643\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 474\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 474\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 727\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 727\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 366\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 366\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1201\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1201\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1116\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1116\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 330\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 330\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 720\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 720\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1432\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1432\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 986\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 986\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1417\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1417\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1263\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1263\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 461\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 461\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 990\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 990\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 808\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 808\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 438\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 438\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 525\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 525\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 759\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 759\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 514\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 514\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1250\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1250\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 327\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 327\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 627\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 627\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1417\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1417\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 301\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1058\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1058\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 508\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 508\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1440\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1440\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 382\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 382\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 164\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 353\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 353\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1280\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1280\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1078\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1078\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1445\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1445\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 611\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 611\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1430\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1430\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1155\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1155\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 301\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 301\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 700\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 700\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 782\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 782\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 424\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 424\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 588\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 588\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1031\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1031\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 562\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 562\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1323\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1323\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 780\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 780\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1461\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 1461\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 456\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 456\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1443\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 1443\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 1191\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1191\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 513\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 513\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1344\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1344\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1470\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1470\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1049\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1049\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 646\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 646\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1170\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1170\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1020\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1020\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 512\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 512\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1406\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1406\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 856\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 856\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 1352\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1352\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 480\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 480\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1351\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 1351\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 1492\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1492\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 927\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 927\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 342\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 342\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 164\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 391\n },\n \"pitchServo\": {\n \"angle\": 720,\n \"speed\": 391\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 540,\n \"speed\": 1090\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1090\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1158\n },\n \"pitchServo\": {\n \"angle\": 180,\n \"speed\": 1158\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 162\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -540,\n \"speed\": 485\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 485\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 163\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 440\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 440\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -900,\n \"speed\": 965\n },\n \"pitchServo\": {\n \"angle\": 850,\n \"speed\": 965\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 900,\n \"speed\": 476\n },\n \"pitchServo\": {\n \"angle\": 50,\n \"speed\": 476\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 486\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 486\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1043\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1043\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1097\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1097\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 1011\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1011\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 1323\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1323\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1412\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1412\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 1116\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1116\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 1409\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1409\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 457\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 457\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 318\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 318\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 336\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 336\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 1014\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1014\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 579\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 579\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 775\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 775\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 765\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 765\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 442\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 442\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 340\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 340\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 1238\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1238\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 651\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 1301\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1301\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 827\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 827\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 356\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 356\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1007\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 1007\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1155\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1155\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 323\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 323\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1486\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1486\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1247\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1247\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1340\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1340\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 822\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 822\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1124\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1124\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1116\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 1116\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 609\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 609\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1300\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 1300\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1061\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1061\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1196\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1196\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1434\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 1434\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 650\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 868\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 868\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 636\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 636\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 1314\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1314\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 468\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 468\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 1112\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1112\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1275\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 1275\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 767\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 767\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 618\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 618\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1393\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1393\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 477\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 477\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1384\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1384\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 1118\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1118\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 804\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 804\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1158\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 1158\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 944\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 944\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1107\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 1107\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1493\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1493\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 730\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 730\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 302\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 1030\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 1030\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 742\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 742\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 967\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 967\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": -420,\n \"speed\": 634\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 634\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 420,\n \"speed\": 931\n },\n \"pitchServo\": {\n \"angle\": 450,\n \"speed\": 931\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 711\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 711\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 326\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1013\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1013\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1359\n },\n \"pitchServo\": {\n \"angle\": 618,\n \"speed\": 1359\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n },\n {\n \"mouth\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 0,\n \"rotation\": 0\n },\n \"leftEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"rightEye\": {\n \"x\": 0,\n \"y\": 0,\n \"size\": 0,\n \"weight\": 100,\n \"rotation\": 0\n },\n \"yawServo\": {\n \"angle\": 0,\n \"speed\": 1343\n },\n \"pitchServo\": {\n \"angle\": 282,\n \"speed\": 1343\n },\n \"leftRgbColor\": \"#AAAAAA\",\n \"rightRgbColor\": \"#AAAAAA\",\n \"durationMs\": 325\n }\n]" +) diff --git a/server/internal/model/web_socket_model.go b/server/internal/model/web_socket_model.go new file mode 100644 index 0000000..97e3fcd --- /dev/null +++ b/server/internal/model/web_socket_model.go @@ -0,0 +1,321 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package model + +import ( + "context" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gorilla/websocket" +) + +type WsSendMsg struct { + MsgType int + Data []byte +} + +type AppClient struct { + mac string + conn *websocket.Conn + mu sync.RWMutex + deviceId string + lastTime time.Time + + sendChan chan *WsSendMsg + ctx context.Context + cancel context.CancelFunc +} + +type StackChanClient struct { + mac string + conn *websocket.Conn + mu sync.RWMutex + cameraSubscriptionList []*AppClient + audioSubscriptionList []*AppClient + callAppClient *AppClient + aimedTakePhotoAppClient *AppClient + phoneScreen bool + lastTime time.Time + + sendChan chan *WsSendMsg + ctx context.Context + cancel context.CancelFunc +} + +// NewAppClient creates and initializes an AppClient +func NewAppClient(mac string, conn *websocket.Conn, deviceId string) *AppClient { + ctx, cancel := context.WithCancel(context.Background()) + client := &AppClient{ + mac: mac, + conn: conn, + deviceId: deviceId, + lastTime: time.Now(), + sendChan: make(chan *WsSendMsg, 100), + ctx: ctx, + cancel: cancel, + } + client.StartWriterCoroutine() + return client +} + +// NewStackChanClient creates and initializes a StackChanClient +func NewStackChanClient(mac string, conn *websocket.Conn, cameraSubscriptionList []*AppClient, callAppClient *AppClient, phoneScreen bool) *StackChanClient { + ctx, cancel := context.WithCancel(context.Background()) + client := &StackChanClient{ + mac: mac, + conn: conn, + cameraSubscriptionList: cameraSubscriptionList, + callAppClient: callAppClient, + phoneScreen: phoneScreen, + lastTime: time.Now(), + sendChan: make(chan *WsSendMsg, 100), + ctx: ctx, + cancel: cancel, + } + client.StartWriterCoroutine() + return client +} + +// StartWriterCoroutine AppClient Start message sending coroutine +func (a *AppClient) StartWriterCoroutine() { + go func() { + defer func() { + if r := recover(); r != nil { + g.Log().Errorf(context.Background(), "AppClient writer coroutine panic: %v", r) + } + close(a.sendChan) + }() + + for { + select { + case <-a.ctx.Done(): + return + case msg, ok := <-a.sendChan: + if !ok { // Channel closed + return + } + if msg == nil { + continue + } + a.mu.RLock() + conn := a.conn + a.mu.RUnlock() + if conn == nil { + continue + } + if err := conn.WriteMessage(msg.MsgType, msg.Data); err != nil { + g.Log().Errorf(context.Background(), "AppClient send message error: %v", err) + } + } + } + }() +} + +// StartWriterCoroutine StackChanClient Start message sending coroutine +func (s *StackChanClient) StartWriterCoroutine() { + go func() { + defer func() { + if r := recover(); r != nil { + g.Log().Errorf(context.Background(), "StackChan writer coroutine panic: %v", r) + } + close(s.sendChan) + }() + for { + select { + case <-s.ctx.Done(): + return + case msg, ok := <-s.sendChan: + if !ok { + return + } + if msg == nil { + continue + } + s.mu.RLock() + conn := s.conn + s.mu.RUnlock() + if conn == nil { + continue + } + if err := conn.WriteMessage(msg.MsgType, msg.Data); err != nil { + g.Log().Errorf(context.Background(), "StackChan writer coroutine send message error: %v", err) + } + } + } + }() +} + +func (a *AppClient) CloseWriterCoroutine() { + a.cancel() +} + +func (s *StackChanClient) CloseWriterCoroutine() { + s.cancel() +} + +func (a *AppClient) SendChan() chan *WsSendMsg { + return a.sendChan +} + +func (s *StackChanClient) SendChan() chan *WsSendMsg { + return s.sendChan +} + +func (a *AppClient) SetMac(mac string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mac = mac +} + +func (a *AppClient) GetMac() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.mac +} + +func (a *AppClient) GetConn() *websocket.Conn { + a.mu.RLock() + defer a.mu.RUnlock() + return a.conn +} + +func (a *AppClient) SetConn(conn *websocket.Conn) { + a.mu.Lock() + defer a.mu.Unlock() + a.conn = conn +} + +func (a *AppClient) SetDeviceId(deviceId string) { + a.mu.Lock() + defer a.mu.Unlock() + a.deviceId = deviceId +} + +func (a *AppClient) GetDeviceId() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.deviceId +} + +func (a *AppClient) SetLastTime(lastTime time.Time) { + a.mu.Lock() + defer a.mu.Unlock() + a.lastTime = lastTime +} + +func (a *AppClient) GetLastTime() time.Time { + a.mu.RLock() + defer a.mu.RUnlock() + return a.lastTime +} + +func (s *StackChanClient) SetMac(mac string) { + s.mu.Lock() + defer s.mu.Unlock() + s.mac = mac +} + +func (s *StackChanClient) GetMac() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.mac +} + +func (s *StackChanClient) GetConn() *websocket.Conn { + s.mu.RLock() + defer s.mu.RUnlock() + return s.conn +} + +func (s *StackChanClient) SetConn(conn *websocket.Conn) { + s.mu.Lock() + defer s.mu.Unlock() + s.conn = conn +} + +func (s *StackChanClient) SetCameraSubscriptionList(cameraSubscriptionList []*AppClient) { + s.mu.Lock() + defer s.mu.Unlock() + s.cameraSubscriptionList = cameraSubscriptionList +} + +func (s *StackChanClient) AppendCameraSubscriptionList(appClient *AppClient) { + s.mu.Lock() + defer s.mu.Unlock() + s.cameraSubscriptionList = append(s.cameraSubscriptionList, appClient) +} + +func (s *StackChanClient) GetCameraSubscriptionList() []*AppClient { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*AppClient, len(s.cameraSubscriptionList)) + copy(out, s.cameraSubscriptionList) + return out +} + +func (s *StackChanClient) SetAudioSubscriptionList(audioSubscriptionList []*AppClient) { + s.mu.Lock() + defer s.mu.Unlock() + s.audioSubscriptionList = audioSubscriptionList +} + +func (s *StackChanClient) GetAudioSubscriptionList() []*AppClient { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*AppClient, len(s.audioSubscriptionList)) + copy(out, s.audioSubscriptionList) + return out +} + +func (s *StackChanClient) SetCallAppClient(client *AppClient) { + s.mu.Lock() + defer s.mu.Unlock() + s.callAppClient = client +} + +func (s *StackChanClient) GetCallAppClient() *AppClient { + s.mu.RLock() + defer s.mu.RUnlock() + return s.callAppClient +} + +func (s *StackChanClient) GetAimedTakePhotoAppClient() *AppClient { + s.mu.RLock() + defer s.mu.RUnlock() + return s.aimedTakePhotoAppClient +} + +func (s *StackChanClient) SetAimedTakePhotoAppClient(aimedTakePhotoAppClient *AppClient) { + s.mu.Lock() + defer s.mu.Unlock() + s.aimedTakePhotoAppClient = aimedTakePhotoAppClient +} + +func (s *StackChanClient) GetPhoneScreen() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.phoneScreen +} + +func (s *StackChanClient) SetPhoneScreen(phoneScreen bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.phoneScreen = phoneScreen +} + +func (s *StackChanClient) GetLastTime() time.Time { + s.mu.RLock() + defer s.mu.RUnlock() + return s.lastTime +} + +func (s *StackChanClient) SetLastTime(lastTime time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastTime = lastTime +} diff --git a/server/internal/model/xiaozhi/agent.go b/server/internal/model/xiaozhi/agent.go new file mode 100644 index 0000000..0ee7246 --- /dev/null +++ b/server/internal/model/xiaozhi/agent.go @@ -0,0 +1,118 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package xiaozhi + +import "time" + +type XiaoZhiResponse[T any] struct { + Success bool `json:"success"` + Data *T `json:"data"` + Message string `json:"message"` + Pagination Pagination `json:"pagination"` + Token string `json:"token"` + Code string `json:"code"` +} + +type Pagination struct { + Total int `json:"total"` + Current int `json:"current"` + PageSize int `json:"pageSize"` + HasMore bool `json:"hasMore"` + Page int `json:"page"` + Limit int `json:"limit"` + TotaPages int `json:"totaPages"` +} + +type ListData[T any] struct { + List []T `json:"list"` + Pagination Pagination `json:"pagination"` +} + +type AgentTemplate struct { + Id int `json:"id"` + DeveloperId int `json:"developer_id"` + AgentName string `json:"agent_name"` + TtsVoices []string `json:"tts_voices"` + DefaultTtsVoice string `json:"default_tts_voice"` + LlmModel string `json:"llm_model"` + AssistantName string `json:"assistant_name"` + UserName string `json:"user_name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Character string `json:"character"` + TtsSpeechSpeed string `json:"tts_speech_speed"` + AsrSpeed string `json:"asr_speed"` + TtsPitch int `json:"tts_pitch"` + KnowledgeBaseIds []int `json:"knowledge_base_ids"` + XiaoZhiVersion string `json:"xiao_zhi_version"` + TtsVoiceName string `json:"tts_voice_name"` +} + +type AgentConfig struct { + AgentName string `json:"agent_name"` + AssistantName string `json:"assistant_name"` + LlmModel string `json:"llm_model"` + TtsVoice string `json:"tts_voice"` + TtsSpeechSpeed string `json:"tts_speech_speed"` + TtsPitch int `json:"tts_pitch"` + AsrSpeed string `json:"asr_speed"` + Language string `json:"language"` + Character string `json:"character"` + Memory string `json:"memory"` + MemoryType string `json:"memory_type"` + KnowledgeBaseIds []int `json:"knowledge_base_ids"` + McpEndpoints []string `json:"mcp_endpoints"` + ProductMcpEndpoints []string `json:"product_mcp_endpoints"` +} + +type CreateAgentResponse struct { + Id int `json:"id"` +} + +// Agent class +type Agent struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + AgentName string `json:"agent_name"` + TtsVoice string `json:"tts_voice"` + LlmModel string `json:"llm_model"` + AssistantName string `json:"assistant_name"` + UserName string `json:"user_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Memory string `json:"memory"` + Character string `json:"character"` + LongMemorySwitch int `json:"long_memory_switch"` + LangCode string `json:"lang_code"` + Language string `json:"language"` + TtsSpeechSpeed string `json:"tts_speech_speed"` + AsrSpeed string `json:"asr_speed"` + TtsPitch int `json:"tts_pitch"` + AgentTemplateID int64 `json:"agent_template_id"` + MemoryUpdatedAt time.Time `json:"memory_updated_at"` + ShareAgentID *int64 `json:"share_agent_id"` + Source string `json:"source"` + McpEndpoints []string `json:"mcp_endpoints"` + MemoryType string `json:"memory_type"` + KnowledgeBaseIDs []int64 `json:"knowledge_base_ids"` + MaxMessageCount *int64 `json:"max_message_count"` + ProductMcpEndpoints []string `json:"product_mcp_endpoints"` + DeviceCount int `json:"deviceCount"` + LastDevice LastDevice `json:"lastDevice"` +} + +// LastDevice nested device struct +type LastDevice struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + MacAddress string `json:"mac_address"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastConnectedAt time.Time `json:"last_connected_at"` + AutoUpdate int `json:"auto_update"` + Alias *string `json:"alias"` + AgentID int64 `json:"agent_id"` +} diff --git a/server/internal/model/xiaozhi/conversation.go b/server/internal/model/xiaozhi/conversation.go new file mode 100644 index 0000000..892d1a7 --- /dev/null +++ b/server/internal/model/xiaozhi/conversation.go @@ -0,0 +1,24 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package xiaozhi + +type Conversation struct { + Id int `json:"id"` + UserId int `json:"user_id"` + CreatedAt string `json:"created_at"` + DeviceId int `json:"device_id"` + MsgCount int `json:"msg_count"` + AgentId int `json:"agent_id"` + Model string `json:"model"` + TokenCount int `json:"token_count"` + Duration int `json:"duration"` + ChatSummary ChatSummary `json:"chat_summary"` +} + +type ChatSummary struct { + Title string `json:"title"` + Summary string `json:"summary"` +} diff --git a/server/internal/model/xiaozhi/device.go b/server/internal/model/xiaozhi/device.go new file mode 100644 index 0000000..efd9dac --- /dev/null +++ b/server/internal/model/xiaozhi/device.go @@ -0,0 +1,24 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package xiaozhi + +type Device struct { + DeviceID int `json:"device_id"` + AgentID int `json:"agent_id"` + ID int `json:"id"` + ProductID int `json:"product_id"` + Seed string `json:"seed"` + SerialNumber string `json:"serial_number"` + ActivateAt string `json:"activate_at"` + ProductName string `json:"product_name"` + MacAddress string `json:"mac_address"` + AppVersion string `json:"app_version"` + BoardName string `json:"board_name"` + ClientId string `json:"client_id"` + IccID string `json:"iccid"` + Imei string `json:"imei"` + Online bool `json:"online"` +} diff --git a/server/internal/service/admin_user.go b/server/internal/service/admin_user.go new file mode 100644 index 0000000..1820405 --- /dev/null +++ b/server/internal/service/admin_user.go @@ -0,0 +1,71 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package service + +import ( + "context" + v1 "stackChan/api/admin/v1" + "stackChan/internal/model" + "time" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" + "github.com/golang-jwt/jwt/v5" +) + +type UserInfo struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func GetJwtSecret() string { + var ctx = gctx.New() + secret := g.Cfg().MustGet(ctx, "jwt.secret").String() + return secret +} + +func AdminLogin(ctx context.Context, req *v1.AdminLoginReq) (res *v1.AdminLoginRes, err error) { + users, err := LoadUserConfig() + if err != nil { + return nil, err + } + var matched *UserInfo + for _, user := range users { + if user.Username == req.UserName && user.Password == req.Password { + matched = &user + break + } + } + if matched == nil { + return nil, gerror.NewCode(gcode.CodeNotAuthorized) + } + claims := jwt.MapClaims{ + model.Username: matched.Username, + model.Exp: time.Now().Add(24 * time.Hour).Unix(), + } + tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + jwtSecret := GetJwtSecret() + if jwtSecret == "" { + return nil, gerror.NewCode(gcode.CodeInternalError) + } + token, err := tokenObj.SignedString([]byte(jwtSecret)) + if err != nil { + return nil, err + } + res = &v1.AdminLoginRes{ + Token: token, + } + return res, nil +} + +func LoadUserConfig() ([]UserInfo, error) { + var ctx = gctx.New() + var users []UserInfo + err := g.Cfg().MustGet(ctx, "admin.users").Scan(&users) + return users, err +} diff --git a/server/internal/service/agent.go b/server/internal/service/agent.go new file mode 100644 index 0000000..3ef6ecf --- /dev/null +++ b/server/internal/service/agent.go @@ -0,0 +1,93 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package service + +import ( + "log" + xiaozhiModel "stackChan/internal/model/xiaozhi" + "stackChan/internal/xiaozhi" + "strings" +) + +// RestoreDefaultAgent / Restore to template agent when unbinding +func RestoreDefaultAgent(mac string) (bool, error) { + // Entry log + log.Printf("[RestoreDefaultAgent] Start restoring device default agent configuration, mac=%s", mac) + + /// First query device information + devices, err := xiaozhi.GetDevices(nil, nil, &mac, nil, nil, nil) + if err != nil { + log.Printf("[RestoreDefaultAgent] Failed to query device information, mac=%s, err=%v", mac, err) + return false, err + } + + if len(*devices) == 0 { + log.Printf("[RestoreDefaultAgent] No device found, mac=%s", mac) + return false, nil + } + + agentID := (*devices)[0].AgentID + + // Fix here: agentID is int -> %d + log.Printf("[RestoreDefaultAgent] Found device agentID=%d, mac=%s", agentID, mac) + + // Get default template + response, err := xiaozhi.GetAgentTemplate(1, 10) + if err != nil { + // Fix + log.Printf("[RestoreDefaultAgent] Failed to get agent template, agentID=%d, mac=%s, err=%v", agentID, mac, err) + return false, err + } + + if len(response.List) == 0 { + // Fix + log.Printf("[RestoreDefaultAgent] Agent template list is empty, agentID=%d, mac=%s", agentID, mac) + return false, nil + } + + agentTemplate := response.List[0] + log.Printf("[RestoreDefaultAgent] Got default agent template, templateName=%s, model=%s", + agentTemplate.AgentName, agentTemplate.LlmModel) + + // Define configuration + var agentConfig = xiaozhiModel.AgentConfig{ + AgentName: agentTemplate.AgentName, + AssistantName: agentTemplate.AssistantName, + LlmModel: agentTemplate.LlmModel, + TtsVoice: getTtsVoice("en", agentTemplate.TtsVoices), + TtsSpeechSpeed: agentTemplate.TtsSpeechSpeed, + TtsPitch: agentTemplate.TtsPitch, + AsrSpeed: agentTemplate.AsrSpeed, + Language: "en", + Character: agentTemplate.Character, + Memory: "", + MemoryType: "OFF", + KnowledgeBaseIds: agentTemplate.KnowledgeBaseIds, + McpEndpoints: nil, + ProductMcpEndpoints: nil, + } + // Start update + return xiaozhi.SetAgentSetting(agentID, agentConfig) +} + +// getItsVoice Get TTS voice based on language, return pure voice name without language prefix +func getTtsVoice(language string, ttsVices []string) string { + prefix := language + ":" + for _, voice := range ttsVices { + if len(voice) >= len(prefix) && voice[:len(prefix)] == prefix { + return voice[len(prefix):] + } + } + if len(ttsVices) > 0 { + for _, voice := range ttsVices { + if idx := strings.Index(voice, ":"); idx != -1 { + return voice[idx+1:] + } + } + return ttsVices[0] + } + return "" +} diff --git a/server/internal/service/apps.go b/server/internal/service/apps.go new file mode 100644 index 0000000..3e2293e --- /dev/null +++ b/server/internal/service/apps.go @@ -0,0 +1,24 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package service + +import ( + "context" + "stackChan/internal/dao" + "stackChan/internal/model" +) + +func GetAppList(ctx context.Context) ([]model.AppInfo, error) { + var apps = make([]model.AppInfo, 0) + err := dao.AppStore. + Ctx(ctx). + Where("is_deleted", 0). + Scan(&apps) + if err != nil { + return nil, err + } + return apps, nil +} diff --git a/server/internal/service/user.go b/server/internal/service/user.go new file mode 100644 index 0000000..08136d6 --- /dev/null +++ b/server/internal/service/user.go @@ -0,0 +1,182 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package service + +import ( + "context" + v2 "stackChan/api/user/v2" + "stackChan/internal/dao" + "stackChan/internal/model" + "stackChan/internal/model/entity" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/guid" + "github.com/golang-jwt/jwt/v5" +) + +const ( + loginUrl = "https://forum.m5stack.com/api/v3/utilities/login" + TokenExpire = 365 * 24 * time.Hour + Issuer = "stackChan" + Audience = "stackChan_user" + RegistrationUrl = "https://forum.m5stack.com/api/v3/users" + RegistrationToken = "Bearer 656515ad-f72f-499f-b716-5db17181642c" +) + +// Login User login +func Login(ctx context.Context, req *v2.LoginReq) (res *v2.LoginRes, err error) { + + if req.Username == "" || req.Password == "" { + return nil, gerror.NewCode(gcode.CodeMissingParameter, "Username / Password cannot be left blank.") + } + + remoteResp, err := callRemoteLogin(ctx, req) + if err != nil { + return nil, err + } + if remoteResp == nil { + return nil, gerror.NewCode(gcode.CodeInvalidParameter, "invalid parameter") + } + if err = saveUserToLocal(ctx, remoteResp); err != nil { + return nil, err + } + token, err := generateToken(remoteResp.Response.Uid) + if err != nil { + return nil, err + } + return &v2.LoginRes{ + Token: token, + }, nil +} + +// callRemoteLogin Call remote login interface +func callRemoteLogin(ctx context.Context, req *v2.LoginReq) (*model.RemoteLoginResp, error) { + remoteLoginResp := &model.RemoteLoginResp{} + clientResp := g.Client().PostVar(ctx, loginUrl, g.Map{ + "username": req.Username, + "password": req.Password, + }) + if clientResp == nil { + g.Log().Errorf(ctx, "Remote login no response, username=%s", req.Username) + return nil, gerror.NewCode(gcode.CodeInternalError, "remote service unavailable") + } + respBody := clientResp.String() + g.Log().Debugf(ctx, "Remote login raw response: %s", respBody) + if strings.Contains(respBody, "[[error:") { + g.Log().Errorf(ctx, "Remote login failed: %s", respBody) + return nil, gerror.NewCode(gcode.CodeBusinessValidationFailed, respBody) + } + err := clientResp.Scan(&remoteLoginResp) + if err != nil { + g.Log().Errorf(ctx, "Login response parsing failed: %+v, raw response: %s", err, respBody) + return nil, gerror.WrapCode(gcode.CodeInternalError, err, respBody) + } + if remoteLoginResp.Status.Code != "ok" { + errMsg := remoteLoginResp.Status.Message + g.Log().Errorf(ctx, "Remote login failed: %s", errMsg) + return nil, gerror.NewCode(gcode.CodeBusinessValidationFailed, remoteLoginResp.Status.Message, errMsg) + } + return remoteLoginResp, nil +} + +// saveUserToLocal Save user to local database +func saveUserToLocal(ctx context.Context, resp *model.RemoteLoginResp) error { + data := entity.User{ + Uid: resp.Response.Uid, + Username: resp.Response.Username, + Userslug: resp.Response.Userslug, + DisplayName: resp.Response.Displayname, + IconText: resp.Response.IconText, + IconBgColor: resp.Response.IconBgColor, + EmailConfirmed: resp.Response.EmailConfirmed, + JoinDate: resp.Response.Joindate, + LastOnline: resp.Response.Lastonline, + UserStatus: resp.Response.Status, + } + _, err := dao.User.Ctx(ctx).Save(data) + if err != nil { + return gerror.WrapCode(gcode.CodeDbOperationError, err, "Failed to write user to local database") + } + return nil +} + +// generateToken Generate JWT token, includes user UID, issuer, audience, issued time, expiration time +func generateToken(uid int64) (string, error) { + now := time.Now() + claims := jwt.MapClaims{ + "jti": guid.S(), // Unique token ID (for revocation/blacklisting) + "id": uid, // User UID + "iss": Issuer, // Issuer (for verification and anti-forgery) + "aud": Audience, // Audience (to limit scope of use) + "iat": now.Unix(), // Issued at time + "exp": now.Add(TokenExpire).Unix(), // Expiration time + } + tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + jwtSecret := GetJwtSecret() + if jwtSecret == "" || len(jwtSecret) < 16 { + return "", gerror.NewCode(gcode.CodeInternalError, "JWT secret is empty or too weak") + } + token, err := tokenObj.SignedString([]byte(jwtSecret)) + if err != nil { + return "", gerror.WrapCode(gcode.CodeInternalError, err, "Failed to generate token") + } + return token, nil +} + +// Registration User registration +func Registration(ctx context.Context, req *v2.RegistrationReq) (res *v2.RegistrationRes, err error) { + if req.UserName == "" || req.Password == "" || req.Email == "" { + return nil, gerror.NewCode(gcode.CodeMissingParameter, "Username/Email/Password cannot be empty") + } + remoteResp, err := callRemoteRegister(ctx, req) + if err != nil { + return nil, err + } + responseData := v2.RegistrationRes(remoteResp) + return &responseData, nil +} + +// callRemoteRegister Call remote registration interface +func callRemoteRegister(ctx context.Context, req *v2.RegistrationReq) (res *model.RegistrationResponse, err error) { + resp := &model.RemoteRegisterResp{} + g.Log().Infof(ctx, "Remote registration request parameters: username=%s, email=%s", req.UserName, req.Email) + + clientResp := g.Client(). + SetHeader("Authorization", RegistrationToken). + PostVar(ctx, RegistrationUrl, g.Map{ + "username": req.UserName, + "email": req.Email, + "password": req.Password, + }) + + if clientResp == nil { + return nil, gerror.NewCode(gcode.CodeInternalError, "remote service unavailable") + } + + respBody := clientResp.String() + g.Log().Debugf(ctx, "Remote registration raw response: %s", respBody) + + if strings.Contains(respBody, "[[error:") { + g.Log().Errorf(ctx, "Remote registration failed: %s", respBody) + return nil, gerror.NewCode(gcode.CodeBusinessValidationFailed, respBody) + } + + err = clientResp.Scan(&resp) + if err != nil { + g.Log().Errorf(ctx, "Registration response parsing failed: %+v, raw response: %s", err, respBody) + return nil, gerror.WrapCode(gcode.CodeInternalError, err, respBody) + } + + if resp.Status.Code != "ok" { + g.Log().Errorf(ctx, "Remote registration business failed: code=%s, message=%s", resp.Status.Code, resp.Status.Message) + return nil, gerror.NewCodef(gcode.CodeBusinessValidationFailed, resp.Status.Message) + } + return &resp.RegistrationResponse, nil +} diff --git a/server/internal/web_socket/socket_task.go b/server/internal/web_socket/socket_task.go new file mode 100644 index 0000000..826baa2 --- /dev/null +++ b/server/internal/web_socket/socket_task.go @@ -0,0 +1,322 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package web_socket + +import ( + "context" + "math/rand" + "stackChan/internal/model" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +var ( + randMu sync.Mutex + rander = rand.New(rand.NewSource(time.Now().UnixNano())) +) + +const ( + ClientExpireTimeout = 15 * time.Second +) + +// StartPingTime sends Ping messages to all connected clients for heartbeat detection +func StartPingTime(ctx context.Context) { + // Global panic recovery, prevent entire heartbeat detection logic from crashing + defer func() { + if r := recover(); r != nil { + logger.Errorf(ctx, "StartPingTime panic recovered: %v", r) + } + }() + + message := createMessage(ping, nil) + messageType := websocket.BinaryMessage + + // Iterate over StackChanClientPool + stackChanClientPool.Range(func(_, value any) bool { + if value == nil { + return true + } + client, ok := value.(*model.StackChanClient) + if !ok { + logger.Warningf(ctx, "StartPingTime: invalid type in StackChanClientPool, skip") + return true + } + if client == nil { + return true + } + + func() { + defer func() { + if r := recover(); r != nil { + logger.Errorf(ctx, "panic in StartPingTime StackChanClientPool forwardMessage: %v", r) + } + }() + if client.GetConn() == nil { + logger.Debugf(ctx, "StartPingTime: StackChanClient %s has nil conn, skip ping", client.GetMac()) + return + } + stackChanSendMessage(ctx, client, &messageType, message) + }() + return true // continue iteration + }) + + // Iterate over AppClientPool + appClientPool.Range(func(_, value any) bool { + if value == nil { + return true + } + clients, ok := value.([]*model.AppClient) + if !ok { + logger.Warningf(ctx, "StartPingTime: invalid type in AppClientPool, skip") + return true + } + if len(clients) == 0 { + return true + } + + for _, client := range clients { + func() { + defer func() { + if r := recover(); r != nil { + logger.Errorf(ctx, "panic in StartPingTime AppClientPool forwardMessage: %v", r) + } + }() + if client == nil { + return + } + if client.GetConn() == nil { + logger.Debugf(ctx, "StartPingTime: AppClient %s (deviceId: %s) has nil conn, skip ping", client.GetMac(), client.GetDeviceId()) + return + } + appSendMessage(ctx, client, &messageType, message) + }() + } + return true // continue iteration + }) +} + +// CheckExpiredLinks checks and cleans up App client connections that have been inactive for over 60 seconds +func CheckExpiredLinks(ctx context.Context) { + defer func() { + if r := recover(); r != nil { + logger.Errorf(ctx, "CheckExpiredLinks panic recovered: %v", r) + } + }() + + now := time.Now() + var expiredClients []*model.AppClient + + // 1. Clean up expired AppClient + appClientPool.Range(func(mac, value any) bool { + if mac == nil || value == nil { + return true + } + clients, ok := value.([]*model.AppClient) + if !ok { + logger.Warningf(ctx, "AppClientPool invalid type for mac: %v, delete invalid entry", mac) + appClientPool.Delete(mac) + return true + } + + newClients := clients[:0] + for _, client := range clients { + if client == nil { + continue + } + if now.Sub(client.GetLastTime()) > ClientExpireTimeout { + stackChanClientPool.Range(func(_, scValue any) bool { + defer func() { + if r := recover(); r != nil { + logger.Errorf(ctx, "Clean StackChanClient panic: %v", r) + } + }() + stackChanClient, ok := scValue.(*model.StackChanClient) + if !ok || stackChanClient == nil { + return true + } + if stackChanClient.GetCallAppClient() == client { + stackChanClient.SetCallAppClient(nil) + } + + //Remove camera subscription + newCamera := make([]*model.AppClient, 0, len(stackChanClient.GetCameraSubscriptionList())) + removedCamera := false + for _, sub := range stackChanClient.GetCameraSubscriptionList() { + if sub != nil && sub != client { + newCamera = append(newCamera, sub) + } else if sub == client { + removedCamera = true + } + } + stackChanClient.SetCameraSubscriptionList(newCamera) + + if removedCamera && len(newCamera) == 0 && stackChanClient.GetConn() != nil { + msg := createMessage(OffCamera, nil) + msgType := websocket.BinaryMessage + stackChanSendMessage(ctx, stackChanClient, &msgType, msg) + } + + //Remove audio subscription + newAudio := make([]*model.AppClient, 0, len(stackChanClient.GetAudioSubscriptionList())) + removedAudio := false + for _, sub := range stackChanClient.GetAudioSubscriptionList() { + if sub != nil && sub != client { + newAudio = append(newAudio, sub) + } else if sub == client { + removedAudio = true + } + } + stackChanClient.SetAudioSubscriptionList(newAudio) + if removedAudio && len(newAudio) == 0 && stackChanClient.GetConn() != nil { + msg := createMessage(OffAudio, nil) + msgType := websocket.BinaryMessage + stackChanSendMessage(ctx, stackChanClient, &msgType, msg) + } + return true + }) + expiredClients = append(expiredClients, client) + } else { + newClients = append(newClients, client) + } + } + if len(newClients) == 0 { + appClientPool.Delete(mac) + } else { + appClientPool.Store(mac, newClients) + } + return true + }) + + for _, client := range expiredClients { + if client == nil { + continue + } + logger.Infof(ctx, "Kicked out expired App client: %s", client.GetMac()) + func() { + defer func() { + if r := recover(); r != nil { + logger.Errorf(ctx, "Close AppClient conn panic: %v", r) + } + }() + client.CloseWriterCoroutine() + if client.GetConn() != nil { + _ = client.GetConn().Close() + client.SetConn(nil) + } + }() + } + + var expiredStackChanKeys []string + stackChanClientPool.Range(func(mac, value any) bool { + if mac == nil || value == nil { + return true + } + macStr, ok := mac.(string) + if !ok { + return true + } + stackChanClient, ok := value.(*model.StackChanClient) + if !ok || stackChanClient == nil { + logger.Warningf(ctx, "StackChanClientPool invalid type for mac: %v, delete invalid entry", macStr) + stackChanClientPool.Delete(mac) + return true + } + if now.Sub(stackChanClient.GetLastTime()) > ClientExpireTimeout { + expiredStackChanKeys = append(expiredStackChanKeys, macStr) + } + return true + }) + + for _, mac := range expiredStackChanKeys { + val, ok := stackChanClientPool.Load(mac) + if !ok { + continue + } + stackChanClient, ok := val.(*model.StackChanClient) + if !ok || stackChanClient == nil { + stackChanClientPool.Delete(mac) + continue + } + + stackChanClientPool.Delete(mac) + + offlineMsg := createStringMessage(DeviceOffline, "Your StackChan is offline.") + msgType := websocket.BinaryMessage + appClients := getAppClients(stackChanClient.GetMac()) + if appClients != nil { + for _, appClient := range appClients { + if appClient == nil { + continue + } + func() { + defer func() { + if r := recover(); r != nil { + logger.Errorf(ctx, "Notify AppClient offline panic: %v", r) + } + }() + appSendMessage(ctx, appClient, &msgType, offlineMsg) + }() + } + } + + logger.Infof(ctx, "Kicked out expired StackChan client: %s", mac) + + stackChanClient.CloseWriterCoroutine() + conn := stackChanClient.GetConn() + stackChanClient.SetConn(nil) + + if conn != nil { + func() { + defer func() { + if r := recover(); r != nil { + logger.Errorf(ctx, "Close StackChan conn panic: %v", r) + } + }() + _ = conn.Close() + }() + } + } +} + +// GetRandomStackChanDevice get Random StackChan Device list +func GetRandomStackChanDevice(userMac string, maxLength int) (list []string) { + if maxLength <= 0 { + return []string{} + } + var macs []string + + stackChanClientPool.Range(func(key, value interface{}) bool { + mac := key.(string) + client := value.(*model.StackChanClient) + + if mac == userMac { + return true + } + online := client.GetConn() != nil + if online { + macs = append(macs, mac) + } + + return true + }) + + if len(macs) == 0 { + return []string{} + } + + randMu.Lock() + rander.Shuffle(len(macs), func(i, j int) { + macs[i], macs[j] = macs[j], macs[i] + }) + randMu.Unlock() + if len(macs) > maxLength { + macs = macs[:maxLength] + } + + return macs +} diff --git a/server/internal/web_socket/web_socket.go b/server/internal/web_socket/web_socket.go index c8f17be..cc6f484 100644 --- a/server/internal/web_socket/web_socket.go +++ b/server/internal/web_socket/web_socket.go @@ -7,12 +7,16 @@ package web_socket import ( "context" + "encoding/base64" "encoding/binary" "errors" - "math/rand" "net" "net/http" + "stackChan/internal/model" "stackChan/internal/service" + "stackChan/utility" + "strconv" + "strings" "sync" "time" @@ -50,6 +54,11 @@ const ( DeviceOffline byte = 0x16 DeviceOnline byte = 0x17 + + OnAudio byte = 0x18 + OffAudio byte = 0x19 + + AimedTakePhoto byte = 0x1A ) var ( @@ -62,33 +71,53 @@ var ( logger = g.Log() stackChanClientPool = sync.Map{} appClientPool = sync.Map{} + appClientMu sync.Mutex ) -// AppClient indicates a WebSocket client connection on the App side -type AppClient struct { - Mac string - Conn *websocket.Conn - mu *sync.RWMutex - DeviceId string - LastTime time.Time -} - -// StackChanClient indicates a WebSocket client connection for the device end of a StackChan -type StackChanClient struct { - Mac string - Conn *websocket.Conn - mu *sync.RWMutex - CameraSubscriptionList []*AppClient - CallAppClient *AppClient - phoneScreen bool - LastTime time.Time +// GetMac get MAC address from request header +func GetMac(r *ghttp.Request) (string, error) { + if token := r.Header.Get(model.Authorization); token != "" { + decodedToken, err := base64.StdEncoding.DecodeString(token) + if err != nil { + logger.Errorf(r.Context(), "Error base64 decoding token: %v", err) + return "", err + } + decrypted, err := utility.RSADecrypt(decodedToken) + if err != nil { + logger.Errorf(r.Context(), "Error decrypting token: %v", err) + return "", err + } + tokenStr := string(decrypted) + parts := strings.Split(tokenStr, "|") + if len(parts) < 2 { + return "", errors.New("invalid token") + } + mac := parts[0] + tsStr := parts[2] + ts, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + return "", errors.New("invalid timestamp") + } + now := time.Now().Unix() + if now-ts > 10 || ts-now > 10 { + return "", errors.New("token expired or not yet valid") + } + return mac, nil + } + return "", nil } +// Handler WebSocket handler function func Handler(r *ghttp.Request) { ctx := r.Context() - mac := r.Get("mac").String() + mac, err := GetMac(r) + if err != nil || mac == "" { + r.Response.WriteHeader(http.StatusUnauthorized) // Return 401 + r.Response.Write("Unauthorized: invalid or missing MAC") + return + } deviceType := r.Get("deviceType").String() - if mac == "" || deviceType == "" { + if deviceType == "" { r.Response.Write("The mac and deviceType parameters are empty.") return } @@ -101,56 +130,55 @@ func Handler(r *ghttp.Request) { if deviceType == "StackChan" { isHave := false - var client *StackChanClient + var client *model.StackChanClient stackChanClientPool.Range(func(key, value any) bool { macAddr := key.(string) - stackChanClient := value.(*StackChanClient) + stackChanClient := value.(*model.StackChanClient) if macAddr == mac { isHave = true client = stackChanClient - client.mu.Lock() - client.Conn = ws - if client.CallAppClient != nil { + client.SetConn(ws) + if client.GetCallAppClient() != nil { reconnectMsg := createStringMessage(TextMessage, "The equipment has been reconnected.") - msgType := websocket.BinaryMessage - forwardMessage(ctx, client.CallAppClient.Conn, &msgType, reconnectMsg, client.CallAppClient.mu) + stackChanSendMessage(ctx, client, new(websocket.BinaryMessage), reconnectMsg) } - if len(client.CameraSubscriptionList) > 0 { + if len(client.GetCameraSubscriptionList()) > 0 { onMsg := createMessage(OnCamera, nil) - onType := websocket.BinaryMessage - forwardMessage(ctx, client.Conn, &onType, onMsg, client.mu) + stackChanSendMessage(ctx, client, new(websocket.BinaryMessage), onMsg) } - client.LastTime = time.Now() - client.mu.Unlock() + if len(client.GetAudioSubscriptionList()) > 0 { + onMsg := createMessage(OnAudio, nil) + stackChanSendMessage(ctx, client, new(websocket.BinaryMessage), onMsg) + } + client.SetLastTime(time.Now()) return false } return true }) if !isHave { - client = &StackChanClient{ - Mac: mac, - Conn: ws, - mu: &sync.RWMutex{}, - phoneScreen: false, - LastTime: time.Now(), - } + client = model.NewStackChanClient(mac, ws, make([]*model.AppClient, 0), nil, false) addStackChenClient(ctx, client) - } else { - // notify app - onlineMsg := createStringMessage(DeviceOnline, "Your StackChan has been launched.") - msgType := websocket.BinaryMessage - // Notify App - appClients := getAppClients(client.Mac) - for _, appClient := range appClients { - forwardMessage(ctx, appClient.Conn, &msgType, onlineMsg, appClient.mu) - } } - logger.Info(ctx, "There is a StackChen connected to the service.", client.Mac) + + // send Online + onlineMsg := createStringMessage(DeviceOnline, "Your StackChan has been launched.") + msgType := websocket.BinaryMessage + // Notify App + appClients := getAppClients(client.GetMac()) + for _, appClient := range appClients { + appSendMessage(ctx, appClient, &msgType, onlineMsg) + } + + logger.Info(ctx, "There is a StackChen connected to the service.", client.GetMac()) defer func() { logger.Info(ctx, "There is a StackChan that has disconnected.", mac, deviceType) + if client.GetConn() != nil { + _ = client.GetConn().Close() + client.SetConn(nil) + } }() for { messageType, msg, err := ws.ReadMessage() @@ -160,8 +188,7 @@ func Handler(r *ghttp.Request) { break } - var ne net.Error - if errors.As(err, &ne) && ne.Temporary() { + if ne, ok := errors.AsType[net.Error](err); ok && ne.Temporary() { logger.Infof(ctx, "StackChan Temporary network error. Continue reading.: mac=%s,deviceType=%s,Error=%v", mac, deviceType, err) continue } @@ -169,7 +196,7 @@ func Handler(r *ghttp.Request) { logger.Errorf(ctx, "StackChan Abnormal disconnection: mac=%s, deviceType=%s, Error=%v", mac, deviceType, err) break } - //logger.Infof(ctx, "收到StackChan端消息%d", len(msg)) + client.SetLastTime(time.Now()) readStackChanMessage(ctx, client, &messageType, &msg) } } else if deviceType == "App" { @@ -178,47 +205,41 @@ func Handler(r *ghttp.Request) { r.Response.Write("The deviceId parameter in the App end is empty.") return } - var client *AppClient + var client *model.AppClient found := false clients := getAppClients(mac) for _, appClient := range clients { - if appClient.DeviceId == deviceId && appClient.Mac == mac { + if appClient.GetDeviceId() == deviceId && appClient.GetMac() == mac { // Already available. Update the connection. client = appClient - client.mu.Lock() - client.Conn = ws - client.mu.Unlock() - client.LastTime = time.Now() + client.SetConn(ws) + client.SetLastTime(time.Now()) found = true break } } if !found { - client = &AppClient{ - Mac: mac, - Conn: ws, - DeviceId: deviceId, - mu: &sync.RWMutex{}, - LastTime: time.Now(), - } + client = model.NewAppClient(mac, ws, deviceId) addAppClient(client) } - logger.Info(ctx, "There is an App connected to the service.", client.Mac) + logger.Info(ctx, "There is an App connected to the service.", client.GetMac()) // check StackChan status - stackChanClient := getStackChanClient(client.Mac) - if stackChanClient == nil { + stackChanClient := getStackChanClient(client.GetMac()) + if stackChanClient == nil || stackChanClient.GetConn() == nil { offlineMsg := createStringMessage(DeviceOffline, "Your StackChan is offline.") - msgType := websocket.BinaryMessage - forwardMessage(ctx, client.Conn, &msgType, offlineMsg, client.mu) + appSendMessage(ctx, client, new(websocket.BinaryMessage), offlineMsg) } else { onlineMsg := createStringMessage(DeviceOnline, "Your StackChan has been launched.") - msgType := websocket.BinaryMessage - forwardMessage(ctx, client.Conn, &msgType, onlineMsg, client.mu) + appSendMessage(ctx, client, new(websocket.BinaryMessage), onlineMsg) } defer func() { logger.Info(ctx, "There is an App that has disconnected.", mac, deviceType) + if client.GetConn() != nil { + _ = client.GetConn().Close() + client.SetConn(nil) + } }() for { messageType, msg, err := ws.ReadMessage() @@ -239,48 +260,50 @@ func Handler(r *ghttp.Request) { logger.Errorf(ctx, "App Abnormal disconnection: mac=%s, deviceType=%s, Error=%v", mac, deviceType, err) break } - client.LastTime = time.Now() + client.SetLastTime(time.Now()) readAppClientMessage(ctx, client, &messageType, &msg) } } } -// addStackChenClient adds a StackChan client to the connection pool and ensures the MAC is registered -func addStackChenClient(ctx context.Context, c *StackChanClient) { - stackChanClientPool.Store(c.Mac, c) - _, _ = service.CreateMacIfNotExists(ctx, c.Mac) +// Handle WebSocket connection requests from StackChan devices +func addStackChenClient(ctx context.Context, c *model.StackChanClient) { + stackChanClientPool.Store(c.GetMac(), c) + _, _ = service.CreateMacIfNotExists(ctx, c.GetMac()) } -// addAppClient adds an App client to the App connection pool (multiple Apps per MAC allowed) -func addAppClient(c *AppClient) { - val, _ := appClientPool.Load(c.Mac) - var clients []*AppClient - if val == nil { - clients = []*AppClient{c} +// Handle WebSocket connection requests from App devices +func addAppClient(c *model.AppClient) { + appClientMu.Lock() + defer appClientMu.Unlock() + + val, _ := appClientPool.Load(c.GetMac()) + var clients []*model.AppClient + if val != nil { + clients = append(val.([]*model.AppClient), c) } else { - clients = val.([]*AppClient) - clients = append(clients, c) + clients = []*model.AppClient{c} } - appClientPool.Store(c.Mac, clients) + appClientPool.Store(c.GetMac(), clients) } -// getAppClients gets all App clients for the specified MAC address -func getAppClients(mac string) []*AppClient { +// Get all App clients with specified MAC address +func getAppClients(mac string) []*model.AppClient { if val, ok := appClientPool.Load(mac); ok { - return val.([]*AppClient) + return val.([]*model.AppClient) } return nil } -// getStackChanClient gets the StackChan client corresponding to the specified MAC address -func getStackChanClient(mac string) *StackChanClient { +// Get StackChan client with specified MAC address +func getStackChanClient(mac string) *model.StackChanClient { if val, ok := stackChanClientPool.Load(mac); ok { - return val.(*StackChanClient) + return val.(*model.StackChanClient) } return nil } -// parseBinaryMessage parses a custom binary protocol message, returns type, length, payload, and success status +// Parse custom binary protocol messages, return message type, data length, payload and success status func parseBinaryMessage(ctx context.Context, msg *[]byte) (byte, int, []byte, bool) { if len(*msg) < 1+4 { logger.Warning(ctx, "Message too short, cannot parse header, message not forwarded") @@ -299,94 +322,8 @@ func parseBinaryMessage(ctx context.Context, msg *[]byte) (byte, int, []byte, bo return msgType, dataLen, payload, true } -// StartPingTime sends Ping messages to all connected clients for heartbeat detection -func StartPingTime(ctx context.Context) { - message := createMessage(ping, nil) - messageType := websocket.BinaryMessage - - // Iterate over StackChanClientPool - stackChanClientPool.Range(func(_, value any) bool { - client := value.(*StackChanClient) - forwardMessage(ctx, client.Conn, &messageType, message, client.mu) - return true // continue iteration - }) - - // Iterate over AppClientPool - appClientPool.Range(func(_, value any) bool { - clients := value.([]*AppClient) - for _, client := range clients { - forwardMessage(ctx, client.Conn, &messageType, message, client.mu) - } - return true // continue iteration - }) -} - -// CheckExpiredLinks checks and cleans up App client connections that have been inactive for over 60 seconds -func CheckExpiredLinks(ctx context.Context) { - now := time.Now() - var expiredClients []*AppClient - - // First, iterate over AppClientPool - appClientPool.Range(func(mac, value any) bool { - clients := value.([]*AppClient) - newClients := clients[:0] - for _, client := range clients { - if now.Sub(client.LastTime) > time.Second*15 { - // Found expired client - // Iterate over StackChanClientPool to clean up CallAppClient and CameraSubscriptionList - stackChanClientPool.Range(func(_, scValue any) bool { - stackChanClient, ok := scValue.(*StackChanClient) - stackChanClient.mu.Lock() - if !ok { - return true - } - // Clean up CallAppClient - if stackChanClient.CallAppClient == client { - stackChanClient.CallAppClient = nil - } - - // Update camera subscription list - newCamera := stackChanClient.CameraSubscriptionList[:0] - removedCamera := false - for _, sub := range stackChanClient.CameraSubscriptionList { - if sub != client { - newCamera = append(newCamera, sub) - } else { - removedCamera = true - } - } - stackChanClient.CameraSubscriptionList = newCamera - stackChanClient.mu.Unlock() - if removedCamera && len(newCamera) == 0 { - msg := createMessage(OffCamera, nil) - msgType := websocket.BinaryMessage - forwardMessage(ctx, stackChanClient.Conn, &msgType, msg, stackChanClient.mu) - } - return true - }) - expiredClients = append(expiredClients, client) - } else { - newClients = append(newClients, client) - } - } - if len(newClients) == 0 { - appClientPool.Delete(mac) - } else { - appClientPool.Store(mac, newClients) - } - return true - }) - - for _, client := range expiredClients { - logger.Infof(ctx, "Kicked out an expired App client: %s", client.Mac) - err := client.Conn.Close() - if err != nil { - } - } -} - -// readStackChanMessage handles messages from the StackChan device side -func readStackChanMessage(ctx context.Context, client *StackChanClient, messageType *int, msg *[]byte) { +// Handle WebSocket messages from StackChan devices +func readStackChanMessage(ctx context.Context, client *model.StackChanClient, messageType *int, msg *[]byte) { if *messageType == websocket.BinaryMessage { msgType, _, _, ok := parseBinaryMessage(ctx, msg) if !ok { @@ -398,56 +335,69 @@ func readStackChanMessage(ctx context.Context, client *StackChanClient, messageT case ControlAvatar, ControlMotion, OnCamera, OffCamera: break case RefuseCall: - // Refused call, remove and notify appClient - appClient := client.CallAppClient + // Reject call, remove and notify App client + appClient := client.GetCallAppClient() if appClient != nil { - forwardMessage(ctx, appClient.Conn, messageType, msg, appClient.mu) - client.mu.Lock() - client.CallAppClient = nil - client.mu.Unlock() + appSendMessage(ctx, appClient, messageType, msg) + client.SetCallAppClient(nil) } break case AgreeCall: - // Agreed to call - appClient := client.CallAppClient + // Accept call, add App client to subscription list + appClient := client.GetCallAppClient() if appClient != nil { - forwardMessage(ctx, appClient.Conn, messageType, msg, appClient.mu) - client.mu.Lock() - client.CameraSubscriptionList = append(client.CameraSubscriptionList, appClient) - client.mu.Unlock() - if len(client.CameraSubscriptionList) == 1 { + appSendMessage(ctx, appClient, messageType, msg) + client.AppendCameraSubscriptionList(appClient) + if len(client.GetCameraSubscriptionList()) == 1 { onMsg := createMessage(OnCamera, nil) onType := websocket.BinaryMessage - forwardMessage(ctx, client.Conn, &onType, onMsg, client.mu) + stackChanSendMessage(ctx, client, &onType, onMsg) + } + client.SetAudioSubscriptionList(append(client.GetAudioSubscriptionList(), appClient)) + if len(client.GetAudioSubscriptionList()) == 1 { + onMsg := createMessage(OnAudio, nil) + onType := websocket.BinaryMessage + stackChanSendMessage(ctx, client, &onType, onMsg) } } break case HangupCall: - // Hang up call - appClient := client.CallAppClient + // Hang up call, remove App client and update subscription list + appClient := client.GetCallAppClient() if appClient != nil { - forwardMessage(ctx, appClient.Conn, messageType, msg, appClient.mu) + appSendMessage(ctx, appClient, messageType, msg) // Remove the client from the subscription list - newList := client.CameraSubscriptionList[:0] - for _, subClient := range client.CameraSubscriptionList { + newList := client.GetCameraSubscriptionList()[:0] + for _, subClient := range client.GetCameraSubscriptionList() { if subClient != appClient { newList = append(newList, subClient) } } - client.mu.Lock() - client.CameraSubscriptionList = newList - client.mu.Unlock() + client.SetCameraSubscriptionList(newList) // If the subscription list is empty, notify to turn off the camera - if len(client.CameraSubscriptionList) == 0 { + if len(client.GetCameraSubscriptionList()) == 0 { offMsg := createMessage(OffCamera, nil) offType := websocket.BinaryMessage - forwardMessage(ctx, client.Conn, &offType, offMsg, client.mu) + stackChanSendMessage(ctx, client, &offType, offMsg) + } + + newAudioList := client.GetAudioSubscriptionList()[:0] + for _, subClient := range client.GetAudioSubscriptionList() { + if subClient != appClient { + newAudioList = append(newAudioList, subClient) + } + } + client.SetAudioSubscriptionList(newAudioList) + if len(client.GetAudioSubscriptionList()) == 0 { + onMsg := createMessage(OnAudio, nil) + onType := websocket.BinaryMessage + stackChanSendMessage(ctx, client, &onType, onMsg) } } break case GetDeviceName: // Query device name - name, err := service.GetDeviceName(ctx, client.Mac) + name, err := service.GetDeviceName(ctx, client.GetMac()) if err != nil { return } @@ -456,50 +406,72 @@ func readStackChanMessage(ctx context.Context, client *StackChanClient, messageT return } newMsg := createStringMessage(GetDeviceName, name) - forwardMessage(ctx, client.Conn, messageType, newMsg, client.mu) + stackChanSendMessage(ctx, client, messageType, newMsg) break case Opus: - + subscribers := client.GetAudioSubscriptionList() + if len(subscribers) > 0 { + var isAll = true + for _, subClient := range client.GetAudioSubscriptionList() { + if subClient.GetConn() != nil { + isAll = false + } + appSendMessage(ctx, subClient, messageType, msg) + } + if isAll { + msg = createMessage(OffAudio, nil) + stackChanSendMessage(ctx, client, messageType, msg) + } + } else { + msg = createMessage(OffAudio, nil) + stackChanSendMessage(ctx, client, messageType, msg) + } break case Jpeg: - subscribers := client.CameraSubscriptionList + subscribers := client.GetCameraSubscriptionList() if len(subscribers) > 0 { var isAll = true for _, subClient := range subscribers { - if subClient.Conn != nil { + if subClient.GetConn() != nil { isAll = false } - forwardMessage(ctx, subClient.Conn, messageType, msg, subClient.mu) + appSendMessage(ctx, subClient, messageType, msg) } if isAll { msg = createMessage(OffCamera, nil) - forwardMessage(ctx, client.Conn, messageType, msg, client.mu) + stackChanSendMessage(ctx, client, messageType, msg) } } else { msg = createMessage(OffCamera, nil) - forwardMessage(ctx, client.Conn, messageType, msg, client.mu) + stackChanSendMessage(ctx, client, messageType, msg) } break case GetAvatarPosture: - appClients := getAppClients(client.Mac) + appClients := getAppClients(client.GetMac()) for _, appClient := range appClients { - forwardMessage(ctx, appClient.Conn, messageType, msg, appClient.mu) + appSendMessage(ctx, appClient, messageType, msg) + } + break + case AimedTakePhoto: + appClient := client.GetAimedTakePhotoAppClient() + if appClient != nil { + appSendMessage(ctx, appClient, messageType, msg) } break default: logger.Infof(ctx, "Unknown binary msgType: %d", msgType) - appClients := getAppClients(client.Mac) + appClients := getAppClients(client.GetMac()) if appClients != nil { for _, appClient := range appClients { - forwardMessage(ctx, appClient.Conn, messageType, msg, appClient.mu) + appSendMessage(ctx, appClient, messageType, msg) } } } } else if *messageType == websocket.TextMessage { - appClients := getAppClients(client.Mac) + appClients := getAppClients(client.GetMac()) if appClients != nil { for _, appClient := range appClients { - forwardMessage(ctx, appClient.Conn, messageType, msg, appClient.mu) + appSendMessage(ctx, appClient, messageType, msg) } } } else if *messageType == websocket.PingMessage { @@ -507,8 +479,8 @@ func readStackChanMessage(ctx context.Context, client *StackChanClient, messageT } } -// readAppClientMessage handles messages from the App side -func readAppClientMessage(ctx context.Context, client *AppClient, messageType *int, msg *[]byte) { +// Handle WebSocket messages from App clients +func readAppClientMessage(ctx context.Context, client *model.AppClient, messageType *int, msg *[]byte) { if *messageType == websocket.BinaryMessage { msgType, _, payload, ok := parseBinaryMessage(ctx, msg) if !ok { @@ -519,7 +491,7 @@ func readAppClientMessage(ctx context.Context, client *AppClient, messageType *i break case GetDeviceName: // Query device name - name, err := service.GetDeviceName(ctx, client.Mac) + name, err := service.GetDeviceName(ctx, client.GetMac()) if err != nil { logger.Errorf(ctx, err.Error()) return @@ -530,22 +502,20 @@ func readAppClientMessage(ctx context.Context, client *AppClient, messageType *i } newMsg := createStringMessage(GetDeviceName, name) logger.Infof(ctx, "Device name found, returning: "+name) - forwardMessage(ctx, client.Conn, messageType, newMsg, client.mu) + appSendMessage(ctx, client, messageType, newMsg) break case UpdateDeviceName: - stackChanClient := getStackChanClient(client.Mac) + stackChanClient := getStackChanClient(client.GetMac()) if stackChanClient != nil { - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) } - appClients := getAppClients(client.Mac) + appClients := getAppClients(client.GetMac()) for _, appClient := range appClients { - forwardMessage(ctx, appClient.Conn, messageType, msg, appClient.mu) + appSendMessage(ctx, appClient, messageType, msg) } break case Opus: - break - case Jpeg: - if len(payload) < 12 { + if payload == nil || len(payload) < 12 { logger.Warningf(ctx, "Payload too short, cannot parse MAC address: %v", payload) return } @@ -555,13 +525,27 @@ func readAppClientMessage(ctx context.Context, client *AppClient, messageType *i newMsg := createMessage(msgType, data) stackChanClient := getStackChanClient(macAddr) if stackChanClient != nil { - if stackChanClient.phoneScreen { - forwardMessage(ctx, stackChanClient.Conn, messageType, newMsg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, newMsg) + } + break + case Jpeg: + if payload == nil || len(payload) < 12 { + logger.Warningf(ctx, "Payload too short, cannot parse MAC address: %v", payload) + return + } + macAddrBytes := payload[:12] + data := payload[12:] + macAddr := string(macAddrBytes) + newMsg := createMessage(msgType, data) + stackChanClient := getStackChanClient(macAddr) + if stackChanClient != nil { + if stackChanClient.GetPhoneScreen() { + stackChanSendMessage(ctx, stackChanClient, messageType, newMsg) } } break case ControlAvatar, ControlMotion: - if len(payload) < 12 { + if payload == nil || len(payload) < 12 { logger.Warningf(ctx, "Payload too short, cannot parse MAC address: %v", payload) return } @@ -571,13 +555,13 @@ func readAppClientMessage(ctx context.Context, client *AppClient, messageType *i newMsg := createMessage(msgType, data) stackChanClient := getStackChanClient(macAddr) if stackChanClient != nil { - forwardMessage(ctx, stackChanClient.Conn, messageType, newMsg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, newMsg) } else { logger.Infof(ctx, "StackChan is currently offline") } break case TextMessage: - if len(payload) < 12 { + if payload == nil || len(payload) < 12 { logger.Warningf(ctx, "Payload too short, cannot parse MAC address: %v", payload) return } @@ -586,18 +570,18 @@ func readAppClientMessage(ctx context.Context, client *AppClient, messageType *i newMsg := createMessage(msgType, data) stackChanClient := getStackChanClient(macAddr) if stackChanClient != nil { - forwardMessage(ctx, stackChanClient.Conn, messageType, newMsg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, newMsg) } appClients := getAppClients(macAddr) if appClients != nil { for _, appClient := range appClients { - forwardMessage(ctx, appClient.Conn, messageType, newMsg, appClient.mu) + appSendMessage(ctx, appClient, messageType, newMsg) } } break case RequestCall: // Request call - if len(payload) < 12 { + if payload == nil || len(payload) < 12 { logger.Warningf(ctx, "Payload too short, cannot parse MAC address: %v", payload) return } @@ -605,76 +589,80 @@ func readAppClientMessage(ctx context.Context, client *AppClient, messageType *i data := payload[12:] stackChanClient := getStackChanClient(macAddr) if stackChanClient != nil { - stackChanClient.mu.Lock() - if stackChanClient.CallAppClient == nil || stackChanClient.CallAppClient == client { - stackChanClient.CallAppClient = client - stackChanClient.mu.Unlock() + if stackChanClient.GetCallAppClient() == nil || stackChanClient.GetCallAppClient() == client { + stackChanClient.SetCallAppClient(client) newMsg := createMessage(msgType, data) - forwardMessage(ctx, stackChanClient.Conn, messageType, newMsg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, newMsg) } else { - stackChanClient.mu.Unlock() // Notify App that the other side is already in a call newMsg := createStringMessage(inCall, "The other party is currently in a call") - forwardMessage(ctx, client.Conn, messageType, newMsg, client.mu) + appSendMessage(ctx, client, messageType, newMsg) } } break case HangupCall: stackChanClientPool.Range(func(_, value any) bool { - stackChanClient := value.(*StackChanClient) - if stackChanClient.CallAppClient == client { + stackChanClient := value.(*model.StackChanClient) + if stackChanClient.GetCallAppClient() == client { // Found corresponding call - stackChanClient.mu.Lock() - stackChanClient.CallAppClient = nil - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) + stackChanClient.SetCallAppClient(nil) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) - newList := stackChanClient.CameraSubscriptionList[:0] - for _, sub := range stackChanClient.CameraSubscriptionList { + newList := stackChanClient.GetCameraSubscriptionList()[:0] + for _, sub := range stackChanClient.GetCameraSubscriptionList() { if sub != client { newList = append(newList, sub) } } - stackChanClient.CameraSubscriptionList = newList - stackChanClient.mu.Unlock() - if len(stackChanClient.CameraSubscriptionList) == 0 { + stackChanClient.SetCameraSubscriptionList(newList) + if len(stackChanClient.GetCameraSubscriptionList()) == 0 { offMsg := createMessage(OffCamera, nil) offType := websocket.BinaryMessage - forwardMessage(ctx, stackChanClient.Conn, &offType, offMsg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, &offType, offMsg) } + + newAudio := stackChanClient.GetAudioSubscriptionList()[:0] + for _, sub := range stackChanClient.GetAudioSubscriptionList() { + if sub != client { + newAudio = append(newAudio, sub) + } + } + stackChanClient.SetAudioSubscriptionList(newAudio) + if len(stackChanClient.GetAudioSubscriptionList()) == 0 { + offMsg := createMessage(OffAudio, nil) + offType := websocket.BinaryMessage + stackChanSendMessage(ctx, stackChanClient, &offType, offMsg) + } + return false } return true }) break - case OnCamera: + case OnAudio: macAddr := string(payload) stackChanClient := getStackChanClient(macAddr) if stackChanClient != nil { - stackChanClient.mu.Lock() alreadySubscribed := false - for _, sub := range stackChanClient.CameraSubscriptionList { + for _, sub := range stackChanClient.GetAudioSubscriptionList() { if sub == client { alreadySubscribed = true break } } + stackChanClient.SetAudioSubscriptionList(append(stackChanClient.GetAudioSubscriptionList(), client)) if !alreadySubscribed { - stackChanClient.CameraSubscriptionList = append(stackChanClient.CameraSubscriptionList, client) - stackChanClient.mu.Unlock() - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) - } else { - stackChanClient.mu.Unlock() + stackChanSendMessage(ctx, stackChanClient, messageType, msg) } } break - case OffCamera: + case OffAudio: macAddr := string(payload) stackChanClient := getStackChanClient(macAddr) if stackChanClient != nil { - stackChanClient.mu.Lock() existed := false - newList := stackChanClient.CameraSubscriptionList[:0] - for _, subClient := range stackChanClient.CameraSubscriptionList { + newList := stackChanClient.GetAudioSubscriptionList()[:0] + for _, subClient := range stackChanClient.GetAudioSubscriptionList() { if subClient == client { existed = true } else { @@ -682,10 +670,42 @@ func readAppClientMessage(ctx context.Context, client *AppClient, messageType *i } } shouldNotify := existed && len(newList) == 0 - stackChanClient.CameraSubscriptionList = newList - stackChanClient.mu.Unlock() + stackChanClient.SetAudioSubscriptionList(newList) if shouldNotify { - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) + } + } + break + case OnCamera: + macAddr := string(payload) + stackChanClient := getStackChanClient(macAddr) + if stackChanClient != nil { + for _, sub := range stackChanClient.GetCameraSubscriptionList() { + if sub == client { + return + } + } + stackChanClient.AppendCameraSubscriptionList(client) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) + } + break + case OffCamera: + macAddr := string(payload) + stackChanClient := getStackChanClient(macAddr) + if stackChanClient != nil { + existed := false + newList := stackChanClient.GetCameraSubscriptionList()[:0] + for _, subClient := range stackChanClient.GetCameraSubscriptionList() { + if subClient == client { + existed = true + } else { + newList = append(newList, subClient) + } + } + shouldNotify := existed && len(newList) == 0 + stackChanClient.SetCameraSubscriptionList(newList) + if shouldNotify { + stackChanSendMessage(ctx, stackChanClient, messageType, msg) } } break @@ -694,13 +714,9 @@ func readAppClientMessage(ctx context.Context, client *AppClient, messageType *i macAddr := string(payload) stackChanClient := getStackChanClient(macAddr) if stackChanClient != nil { - stackChanClient.mu.Lock() - if stackChanClient.phoneScreen == false { - stackChanClient.phoneScreen = true - stackChanClient.mu.Unlock() - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) - } else { - stackChanClient.mu.Unlock() + if stackChanClient.GetPhoneScreen() == false { + stackChanClient.SetPhoneScreen(true) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) } } break @@ -709,63 +725,92 @@ func readAppClientMessage(ctx context.Context, client *AppClient, messageType *i macAddr := string(payload) stackChanClient := getStackChanClient(macAddr) if stackChanClient != nil { - stackChanClient.mu.Lock() - if stackChanClient.phoneScreen == true { - stackChanClient.phoneScreen = false - stackChanClient.mu.Unlock() - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) - } else { - stackChanClient.mu.Unlock() + if stackChanClient.GetPhoneScreen() == true { + stackChanClient.SetPhoneScreen(false) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) } } break case Dance: // Dance message - stackChanClient := getStackChanClient(client.Mac) + stackChanClient := getStackChanClient(client.GetMac()) if stackChanClient != nil { - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) } break case GetAvatarPosture: - stackChanClient := getStackChanClient(client.Mac) + stackChanClient := getStackChanClient(client.GetMac()) if stackChanClient != nil { - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) } + case AimedTakePhoto: + stackChanClient := getStackChanClient(client.GetMac()) + if stackChanClient != nil { + stackChanClient.SetAimedTakePhotoAppClient(client) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) + } + break default: logger.Infof(ctx, "Unknown binary msgType: %d", msgType) - stackChanClient := getStackChanClient(client.Mac) + stackChanClient := getStackChanClient(client.GetMac()) if stackChanClient != nil { - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) } } } else if *messageType == websocket.TextMessage { // Directly forward other message types - stackChanClient := getStackChanClient(client.Mac) + stackChanClient := getStackChanClient(client.GetMac()) if stackChanClient != nil { - forwardMessage(ctx, stackChanClient.Conn, messageType, msg, stackChanClient.mu) + stackChanSendMessage(ctx, stackChanClient, messageType, msg) } } else if *messageType == websocket.PingMessage { logger.Info(ctx, "Received ping message from App side") } } -// forwardMessage forwards a message to the specified connection, with mutex for concurrency safety -func forwardMessage(ctx context.Context, conn *websocket.Conn, messageType *int, msg *[]byte, mu *sync.RWMutex) { - if conn == nil { - logger.Infof(ctx, "StackChan is currently offline") - return - } - mu.Lock() - defer mu.Unlock() - err := conn.WriteMessage(*messageType, *msg) - if err != nil { - //logger.Info(ctx, "Message forwarding failed: %v", err) - } else { - //logger.Info(ctx, "Message sent successfully") +// Send WebSocket messages to App clients +func appSendMessage(ctx context.Context, client *model.AppClient, messageType *int, msg *[]byte) { + select { + case client.SendChan() <- &model.WsSendMsg{ + MsgType: *messageType, + Data: *msg, + }: + default: + logger.Infof(ctx, "App client send message is full") } } -// createMessage encapsulates a binary message according to custom protocol (type + length + data) +// Send WebSocket messages to StackChan devices +func stackChanSendMessage(ctx context.Context, client *model.StackChanClient, messageType *int, msg *[]byte) { + select { + case client.SendChan() <- &model.WsSendMsg{ + MsgType: *messageType, + Data: *msg, + }: + default: + logger.Infof(ctx, "StackChan client send message is full") + } +} + +// SendAppMessage Send WebSocket messages to App clients +func SendAppMessage(ctx context.Context, mac string, messageType *int, msg *[]byte, supportOfflineMode *bool) { + clients := getAppClients(mac) + if clients != nil { + for _, client := range clients { + appSendMessage(ctx, client, messageType, msg) + } + } +} + +// SendStackChanMessage Send WebSocket messages to StackChan devices +func SendStackChanMessage(ctx context.Context, mac string, messageType *int, msg *[]byte, supportOfflineMode *bool) { + stackChanClient := getStackChanClient(mac) + if stackChanClient != nil { + stackChanSendMessage(ctx, stackChanClient, messageType, msg) + } +} + +// Encapsulate binary messages for custom protocol (type + data length + data) func createMessage(msgType byte, data []byte) *[]byte { var dataLen int if data != nil { @@ -782,49 +827,7 @@ func createMessage(msgType byte, data []byte) *[]byte { return &msg } -// createStringMessage creates a binary message with a string payload +// Encapsulate binary messages for custom protocol (type + data length + string data) func createStringMessage(msgType byte, data string) *[]byte { return createMessage(msgType, []byte(data)) } - -// GetRandomStackChanDevice get Random StackChan Device list -func GetRandomStackChanDevice(userMac string, maxLength int) (list []string) { - if maxLength <= 0 { - return []string{} - } - var macs []string - - stackChanClientPool.Range(func(key, value interface{}) bool { - mac := key.(string) - client := value.(*StackChanClient) - - if mac == userMac { - return true - } - - client.mu.RLock() - online := client.Conn != nil - client.mu.RUnlock() - - if online { - macs = append(macs, mac) - } - - return true - }) - - if len(macs) == 0 { - return []string{} - } - - r := rand.New(rand.NewSource(time.Now().UnixNano())) - r.Shuffle(len(macs), func(i, j int) { - macs[i], macs[j] = macs[j], macs[i] - }) - - if len(macs) > maxLength { - macs = macs[:maxLength] - } - - return macs -} diff --git a/server/internal/xiaozhi/xiaozhi.go b/server/internal/xiaozhi/xiaozhi.go new file mode 100644 index 0000000..b0e85de --- /dev/null +++ b/server/internal/xiaozhi/xiaozhi.go @@ -0,0 +1,532 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package xiaozhi + +import ( + "errors" + "fmt" + "regexp" + "stackChan/internal/model" + "stackChan/internal/model/xiaozhi" + "strconv" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" + "github.com/gogf/gf/v2/os/gctx" +) + +var ( + ctx = gctx.New() + token string // Memory stored Token + tokenExpire time.Time // Token expiration time + mu sync.Mutex // Mutex lock, ensure Token update thread-safe + ticker *time.Ticker // 24-hour timer + macSeparatorRegex = regexp.MustCompile(`[^0-9a-fA-F]`) + validMacRegex = regexp.MustCompile(`^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$`) + globalClient *gclient.Client // Global HTTP client +) + +const ( + baseUrl = "https://xiaozhi.me/" + tokenPath = "api/developers/token" + agentTemplatesList = "api/developers/agent-templates/list" + devices = "api/developers/devices" + deviceUnbind = "api/developers/unbind-device" + agentsDelete = "api/agents/delete" + createAgent = "api/agents" + chats = "api/chats/list" + tokenExpiry = 24 * time.Hour // Token valid for 24 hours + agents = "api/agents" +) + +func init() { + // Initialize global HTTP client + globalClient = g.Client() + globalClient.SetTimeout(10 * time.Second) + globalClient.SetHeader("Content-Type", "application/json") + + // Start 24-hour timer to periodically check and refresh Token + ticker = time.NewTicker(tokenExpiry) + g.Log().Info(ctx, "xiaozhi token auto refresh ticker started, refresh cycle: 24 hours") + + go func() { + for range ticker.C { + mu.Lock() + // Force clear Token, next GetToken call will auto-refresh + token = "" + tokenExpire = time.Time{} + mu.Unlock() + g.Log().Info(ctx, "token expired, auto refresh") + } + }() +} + +// Unified request processing method, auto handle Session expiration +func doRequest(method string, path string, data interface{}, resp interface{}) error { + // Get token + tokenString, err := GetToken() + if err != nil { + return err + } + + // Clone client and set Authorization header + client := globalClient.Clone() + client.SetHeader("Authorization", "Bearer "+tokenString) + + var response *g.Var + + // Send request based on HTTP method + switch strings.ToUpper(method) { + case "GET": + fullUrl := baseUrl + path + if params, ok := data.(g.Map); ok && len(params) > 0 { + var queryParts []string + for k, v := range params { + queryParts = append(queryParts, fmt.Sprintf("%s=%v", k, v)) + } + queryStr := strings.Join(queryParts, "&") + if strings.Contains(fullUrl, "?") { + fullUrl += "&" + queryStr + } else { + fullUrl += "?" + queryStr + } + } + response = client.GetVar(ctx, fullUrl) + case "POST": + response = client.PostVar(ctx, baseUrl+path, data) + case "PUT": + response = client.PutVar(ctx, baseUrl+path, data) + case "DELETE": + response = client.DeleteVar(ctx, baseUrl+path, data) + default: + return fmt.Errorf("unsupported request method: %s", method) + } + + err = response.Scan(resp) + if err != nil { + return err + } + + // Check if response is successful, handle Session expiration + json := gjson.New(response.Val()) + success := json.Get("success").Bool() + message := json.Get("message").String() + if !success { + if message == "Session expired or logged out" { + g.Log().Info(ctx, "session expired or logged out, auto refresh token") + mu.Lock() + token = "" + tokenExpire = time.Time{} + mu.Unlock() + return doRequest(method, path, data, resp) + } + } + return nil +} + +// GetToken Get Token (thread-safe, 24-hour auto-expiration) +func GetToken() (string, error) { + mu.Lock() + defer mu.Unlock() + + // 1. Check if Token exists and not expired + if token != "" && time.Now().Before(tokenExpire) { + g.Log().Debug(ctx, "token expired at: %s", tokenExpire.Format("2006-01-02 15:04:05")) + return token, nil + } + + g.Log().Info(ctx, "token expired or not exist, auto refresh") + // 2. Token does not exist/expired, refresh to get + newToken, err := refreshToken() + if err != nil { + g.Log().Error(ctx, "refresh token failed: %v", err) + return "", err + } + + // 3. Update Token and expiration time + token = newToken + tokenExpire = time.Now().Add(tokenExpiry) // Record expiration time (current time + 24 hours) + g.Log().Info(ctx, "refresh token success, expire at: %s", tokenExpire.Format("2006-01-02 15:04:05")) + + return token, nil +} + +func GetNewToken() (string, error) { + mu.Lock() + token = "" + tokenExpire = time.Time{} + mu.Unlock() + return GetToken() +} + +// DeleteAgent Delete agent +func DeleteAgent(agentId int) (bool, error) { + params := g.Map{ + "id": agentId, + } + + var resp xiaozhi.XiaoZhiResponse[model.Empty] + err := doRequest("POST", agentsDelete, params, &resp) + if err != nil { + g.Log().Error(ctx, "delete agent failed: %v", err) + return false, err + } + return true, nil +} + +func CreateAgent(params g.Map) (*int, error) { + var resp xiaozhi.XiaoZhiResponse[xiaozhi.CreateAgentResponse] + err := doRequest("POST", createAgent, params, &resp) + if err != nil { + g.Log().Error(ctx, "create agent failed: %v", err) + return nil, err + } + return &resp.Data.Id, nil +} + +// GetAgentTemplate Get agent template +func GetAgentTemplate(page int, pageSize int) (*xiaozhi.ListData[xiaozhi.AgentTemplate], error) { + g.Log().Debug(ctx, "Get agent template, page: ", page, " pageSize: ", pageSize) + queryMap := g.Map{ + "page": page, + "pageSize": pageSize, + } + + var resp xiaozhi.XiaoZhiResponse[xiaozhi.ListData[xiaozhi.AgentTemplate]] + err := doRequest("GET", agentTemplatesList, queryMap, &resp) + if err != nil { + g.Log().Error(ctx, "Get agent template failed: %v", err) + return nil, err + } + + g.Log().Info(ctx, + "Get agent template success, list length: ", len(resp.Data.List), + " total count: ", resp.Pagination.Total, + ) + + return resp.Data, nil +} + +// SetAgentSetting Update agent settings +func SetAgentSetting(agentId int, parameters xiaozhi.AgentConfig) (bool, error) { + path := "api/agents/" + strconv.Itoa(agentId) + "/config" + url := baseUrl + path + g.Log().Info(ctx, "Update agent setting, agentId:", agentId, "url: ", url) + g.Log().Info(ctx, "Request body parameters: ", gjson.MustEncodeString(parameters)) + + var resp xiaozhi.XiaoZhiResponse[model.Empty] + err := doRequest("POST", path, parameters, &resp) + if err != nil { + g.Log().Error(ctx, "Update agent setting failed: %v", err) + return false, err + } + + g.Log().Info(ctx, "Update agent setting success, agentId:", agentId) + return true, nil +} + +// GetDevices Get device list +func GetDevices( + page *int, + pageSize *int, + macAddress *string, + serialNumber *string, + productID *int, + DeviceID *int, +) (*[]xiaozhi.Device, error) { + + newMacAddress := formatMac(macAddress) + + // Added: Request parameter logging + + queryMap := g.Map{} + if page != nil { + queryMap["page"] = *page + } + if pageSize != nil { + queryMap["pageSize"] = *pageSize + } + if macAddress != nil { + queryMap["mac_address"] = *newMacAddress + } + if serialNumber != nil { + queryMap["serial_number"] = *serialNumber + } + if productID != nil { + queryMap["product_id"] = *productID + } + if DeviceID != nil { + queryMap["device_id"] = *DeviceID + } + + g.Log().Debug(ctx, + "Get device list, data:", queryMap, + ) + + var resp xiaozhi.XiaoZhiResponse[xiaozhi.ListData[xiaozhi.Device]] + err := doRequest("GET", devices, queryMap, &resp) + if err != nil { + g.Log().Error(ctx, "Get device list failed: %v", err) + return nil, err + } + + g.Log().Info(ctx, "Get device list success, list length:", len(resp.Data.List), " total count:", resp.Pagination.Total) + + g.Log().Info(ctx, "Get device list success, first device:", resp.Data.List[0]) + + return &resp.Data.List, nil +} + +func GetAgents(page *int, pageSize *int, keyword *string) (*[]xiaozhi.Agent, error) { + // Added: Request parameter logging + g.Log().Debug(ctx, + "Get agent list, page:", page, + " pageSize:", pageSize, + " keyword:", keyword, + ) + queryMap := g.Map{} + if keyword != nil { + queryMap["keyword"] = *keyword + } + if page != nil { + queryMap["page"] = *page + } + if pageSize != nil { + queryMap["pageSize"] = *pageSize + } + var resp xiaozhi.XiaoZhiResponse[[]xiaozhi.Agent] + err := doRequest("GET", agents, queryMap, &resp) + if err != nil { + g.Log().Error(ctx, "Get agent list failed: %v", err) + return nil, err + } + g.Log().Info(ctx, + "Get agent list success, list length:", len(*resp.Data), + " total count:", resp.Pagination.Total, + ) + + return resp.Data, nil +} + +func formatMac(mac *string) *string { + if mac == nil { + return nil + } + cleanMac := macSeparatorRegex.ReplaceAllString(*mac, "") + if !isValidMac(cleanMac) { + return nil + } + cleanMac = strings.ToLower(cleanMac) + var parts []string + for i := 0; i < 12; i += 2 { + parts = append(parts, cleanMac[i:i+2]) + } + return new(strings.Join(parts, ":")) +} + +func isValidMac(cleanMac string) bool { + return len(cleanMac) == 12 +} + +type ZhiGetToken struct { + Token string `json:"token"` +} + +// refreshToken Refresh Token from server (core logic) +func refreshToken() (string, error) { + secretKey := g.Cfg().MustGet(ctx, "xiaozhi.secret_key").String() + if secretKey == "" { + g.Log().Error(ctx, "xiaozhi.secret_key is empty, please check config file") + return "", errors.New("xiaozhi.secret_key is empty") + } + + g.Log().Debug(ctx, "refresh token") + requestData := g.Map{ + "secret_key": secretKey, + } + + client := g.Client() + client.SetTimeout(10 * time.Second) + client.SetHeader("Content-Type", "application/json") + + var resp xiaozhi.XiaoZhiResponse[ZhiGetToken] + err := client.PostVar(ctx, baseUrl+tokenPath, requestData).Scan(&resp) + if err != nil { + g.Log().Error(ctx, "refresh token failed: %v", err) + return "", fmt.Errorf("refresh token failed: %w", err) + } + + if !resp.Success { + g.Log().Error(ctx, "refresh token failed: %s", resp.Message) + return "", fmt.Errorf("refresh token failed: %s", resp.Message) + } + + // Generic structure direct value, no need for map assertion!!! + if resp.Data.Token == "" { + g.Log().Error(ctx, "refresh token failed: token is empty") + return "", fmt.Errorf("token is empty") + } + + g.Log().Debug(ctx, "refresh token success") + return resp.Data.Token, nil +} + +// UnbindDevice Unbind device from XiaoZhi side +// @param macAddress Device MAC address +func UnbindDevice(macAddress *string) (bool, error) { + g.Log().Debug(ctx, "unbind device") + + // First query device ID + devices, err := GetDevices(new(1), new(10), macAddress, nil, nil, nil) + if err != nil { + g.Log().Error(ctx, err.Error()) + return false, err + } + + if len(*devices) == 0 { + g.Log().Error(ctx, "unbind device failed: device not found, mac=%s", *macAddress) + /// Device not found, return true + return true, nil + } + deviceID := (*devices)[0].DeviceID + + requestData := g.Map{ + "device_id": deviceID, + } + + g.Log().Info(ctx, "unbind device, device_id: ", (*devices)[0]) + g.Log().Info(ctx, "request data: ", gjson.MustEncodeString(requestData)) + + var resp xiaozhi.XiaoZhiResponse[model.Empty] + err = doRequest("POST", deviceUnbind, requestData, &resp) + if err != nil { + g.Log().Error(ctx, "unbind device failed: %v", err) + return false, err + } + if !resp.Success { + if resp.Message == "device not found" { + g.Log().Info(ctx, "unbind device failed: device not found") + return true, nil + } + g.Log().Error(ctx, "unbind device failed: %s", resp.Message) + return false, nil + } + g.Log().Info(ctx, "unbind device success, device_id: ", deviceID) + g.Log().Info(ctx, resp.Message) + + return true, nil +} + +// UpdateAllDevices / Temporary script code, update mcp tools for all devices +func UpdateAllDevices() (bool, error) { + // Initial pagination values + page := 1 + pageSize := 100 + + // Loop through pages until no more data + for { + // Get current page device list + agents, err := GetAgents(&page, &pageSize, nil) + if err != nil { + return false, err + } + + // If no data on current page, all pages processed, exit loop + if agents == nil || len(*agents) == 0 { + g.Log().Info(ctx, "update all devices success") + break + } + + g.Log().Info(ctx, "update all devices, page: ", page, ", total: ", len(*agents)) + + for _, agent := range *agents { + agentId := agent.ID + + parameters := xiaozhi.AgentConfig{ + AgentName: agent.AgentName, + AssistantName: agent.AssistantName, + LlmModel: agent.LlmModel, + TtsVoice: agent.TtsVoice, + TtsSpeechSpeed: agent.TtsSpeechSpeed, + TtsPitch: agent.TtsPitch, + AsrSpeed: agent.AsrSpeed, + Language: agent.Language, + Character: agent.Character, + Memory: agent.Memory, + MemoryType: agent.MemoryType, + KnowledgeBaseIds: []int{}, + McpEndpoints: nil, + ProductMcpEndpoints: nil, + } + + path := "api/agents/" + strconv.FormatInt(agentId, 10) + "/config" + url := baseUrl + path + g.Log().Info(ctx, "update agent config, agentId: ", agentId, "url: ", url) + g.Log().Info(ctx, "request data: ", gjson.MustEncodeString(parameters)) + + var resp xiaozhi.XiaoZhiResponse[model.Empty] + err := doRequest("POST", path, parameters, &resp) + if err != nil { + g.Log().Error(ctx, "update agent config failed: %v", err) + return false, err + } + + if !resp.Success { + g.Log().Info(ctx, "update agent config failed: %s", resp.Message) + continue + } + + g.Log().Info(ctx, "update agent config success, agentId: ", agentId) + g.Log().Info(ctx, "update agent config success, agentId: ", agentId) + } + + // Page number +1, continue requesting next page + page++ + } + + return true, nil +} + +func DeleteChats() { + page := 1 + pageSize := 100 + requestData := g.Map{ + "page": page, + "size": pageSize, + } + + var resp xiaozhi.XiaoZhiResponse[xiaozhi.ListData[xiaozhi.Conversation]] + err := doRequest("GET", chats, requestData, &resp) + if err != nil { + return + } + + if !resp.Success { + return + } + + list := resp.Data.List + + for _, item := range list { + + url := "api/agents/" + strconv.Itoa(item.AgentId) + "/chats/" + strconv.Itoa(item.Id) + + var deleResp xiaozhi.XiaoZhiResponse[model.Empty] + err := doRequest("DELETE", url, nil, &deleResp) + if err != nil { + g.Log().Error(ctx, "delete chat failed, agentId:", item.AgentId, " chatId:", item.Id, " error:", err) + continue + } + + g.Log().Info(ctx, "delete chat success, agentId:", item.AgentId, " chatId:", item.Id) + + } + +} diff --git a/server/main.go b/server/main.go index eccb966..b672d47 100644 --- a/server/main.go +++ b/server/main.go @@ -9,7 +9,7 @@ import ( "stackChan/internal/cmd" _ "stackChan/internal/packed" - _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" "github.com/gogf/gf/v2/os/gctx" ) diff --git a/server/manifest/config/config.yaml b/server/manifest/config/config.yaml index 41ab5d8..3f34517 100644 --- a/server/manifest/config/config.yaml +++ b/server/manifest/config/config.yaml @@ -1,15 +1,41 @@ # https://goframe.org/docs/web/server-config-file-template server: - address: ":12800" -# openapiPath: "/api.json" + address: ":12800" + openapiPath: "/api.json" swaggerPath: "/swagger" # https://goframe.org/docs/core/glog-config logger: - level : "all" - stdout: true + path: "./logs" # 日志目录(相对或绝对路径) + file: "{Y-m-d}.log" # 按天滚动 + level: "all" # 日志级别:all | debug | info | warning | error + stdout: true # 是否同时输出到控制台 + rotateExpire: "7d" # 日志保留时间 + rotateBackup: 10 # 最多保留文件数 + rotateSize: "50M" # 单文件最大大小 # https://goframe.org/docs/core/gdb-config-file database: default: - link: "sqlite::@file(/stackChan.sqlite)" \ No newline at end of file + link: "" + + +jwt: + secret: "" + +admin: + users: + - username: "" + password: "" + +rsa: + server: + public: + private: + client: + public: + private: + +xiaozhi: + secret_key: + generate_license_token: \ No newline at end of file diff --git a/server/manifest/deploy/kustomize/base/deployment.yaml b/server/manifest/deploy/kustomize/base/deployment.yaml index 28f1d69..ebe60bf 100644 --- a/server/manifest/deploy/kustomize/base/deployment.yaml +++ b/server/manifest/deploy/kustomize/base/deployment.yaml @@ -1,4 +1,4 @@ -apiVersion: apps/v1 +apiVersion: apps/v2 kind: Deployment metadata: name: template-single diff --git a/server/manifest/deploy/kustomize/base/service.yaml b/server/manifest/deploy/kustomize/base/service.yaml index 608771c..8e0fda9 100644 --- a/server/manifest/deploy/kustomize/base/service.yaml +++ b/server/manifest/deploy/kustomize/base/service.yaml @@ -1,4 +1,4 @@ -apiVersion: v1 +apiVersion: v2 kind: Service metadata: name: template-single diff --git a/server/manifest/deploy/kustomize/overlays/develop/configmap.yaml b/server/manifest/deploy/kustomize/overlays/develop/configmap.yaml index 3b1d0af..997b393 100644 --- a/server/manifest/deploy/kustomize/overlays/develop/configmap.yaml +++ b/server/manifest/deploy/kustomize/overlays/develop/configmap.yaml @@ -1,4 +1,4 @@ -apiVersion: v1 +apiVersion: v2 kind: ConfigMap metadata: name: template-single-configmap diff --git a/server/manifest/deploy/kustomize/overlays/develop/deployment.yaml b/server/manifest/deploy/kustomize/overlays/develop/deployment.yaml index 04e4851..9d96737 100644 --- a/server/manifest/deploy/kustomize/overlays/develop/deployment.yaml +++ b/server/manifest/deploy/kustomize/overlays/develop/deployment.yaml @@ -1,4 +1,4 @@ -apiVersion: apps/v1 +apiVersion: apps/v2 kind: Deployment metadata: name: template-single diff --git a/server/stackChan.sqlite b/server/stackChan.sqlite deleted file mode 100644 index 066b3dc..0000000 Binary files a/server/stackChan.sqlite and /dev/null differ diff --git a/server/utility/rsa.go b/server/utility/rsa.go new file mode 100644 index 0000000..ea028db --- /dev/null +++ b/server/utility/rsa.go @@ -0,0 +1,334 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +package utility + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" +) + +var ( + serverPublicKey *rsa.PublicKey + serverPrivateKey *rsa.PrivateKey + clientPublicKey *rsa.PublicKey + clientPrivateKey *rsa.PrivateKey + initialized bool +) + +func init() { + if err := InitRSAKeys(); err != nil { + panic(err) + } +} + +// InitRSAKeys Initialize RSA keys from configuration file +func InitRSAKeys() error { + if initialized { + return nil + } + + var ctx = gctx.New() + + // Read key strings from configuration file + serverPublicKeyStr := g.Cfg().MustGet(ctx, "rsa.server.public").String() + serverPrivateKeyStr := g.Cfg().MustGet(ctx, "rsa.server.private").String() + clientPublicKeyStr := g.Cfg().MustGet(ctx, "rsa.client.public").String() + clientPrivateKeyStr := g.Cfg().MustGet(ctx, "rsa.client.private").String() + + // Check if keys are empty + if serverPublicKeyStr == "" { + return errors.New("server public key not found in config") + } + if serverPrivateKeyStr == "" { + return errors.New("server private key not found in config") + } + if clientPublicKeyStr == "" { + return errors.New("client public key not found in config") + } + if clientPrivateKeyStr == "" { + return errors.New("client private key not found in config") + } + + // Parse server public key + var err error + serverPublicKey, err = parsePublicKey([]byte(serverPublicKeyStr)) + if err != nil { + return errors.New("failed to parse server public key: " + err.Error()) + } + + // Parse server private key + serverPrivateKey, err = parsePrivateKey([]byte(serverPrivateKeyStr)) + if err != nil { + return errors.New("failed to parse server private key: " + err.Error()) + } + + // Parse client public key + clientPublicKey, err = parsePublicKey([]byte(clientPublicKeyStr)) + if err != nil { + return errors.New("failed to parse client public key: " + err.Error()) + } + + // Parse client private key + clientPrivateKey, err = parsePrivateKey([]byte(clientPrivateKeyStr)) + if err != nil { + return errors.New("failed to parse client private key: " + err.Error()) + } + + initialized = true + return nil +} + +// parsePublicKey Parse public key in PEM format +func parsePublicKey(pemBytes []byte) (*rsa.PublicKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("failed to parse PEM block containing public key") + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + // Try parsing PKCS1 format + pub, err = x509.ParsePKCS1PublicKey(block.Bytes) + if err != nil { + return nil, errors.New("failed to parse public key as PKIX or PKCS1: " + err.Error()) + } + } + + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, errors.New("not an RSA public key") + } + + return rsaPub, nil +} + +// parsePrivateKey Parse private key in PEM format +func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("failed to parse PEM block containing private key") + } + + var priv *rsa.PrivateKey + var err error + + // Try parsing PKCS1 format + priv, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Try parsing PKCS8 format + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, errors.New("failed to parse private key as PKCS1 or PKCS8: " + err.Error()) + } + var ok bool + priv, ok = key.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("not an RSA private key") + } + } + + return priv, nil +} + +// RSAEncrypt Encrypt using client public key (used when server sends data to client) +func RSAEncrypt(plainText []byte) ([]byte, error) { + if !initialized { + return nil, errors.New("RSA keys not initialized") + } + + // Use OAEP padding, SHA256 hash + hash := sha256.New() + ciphertext, err := rsa.EncryptOAEP(hash, rand.Reader, clientPublicKey, plainText, nil) + if err != nil { + return nil, err + } + + return ciphertext, nil +} + +// RSADecrypt Decrypt using server private key (used when server receives client data) +func RSADecrypt(cipherText []byte) ([]byte, error) { + if !initialized { + return nil, errors.New("RSA keys not initialized") + } + + // Use OAEP padding, SHA256 hash + hash := sha256.New() + plaintext, err := rsa.DecryptOAEP(hash, rand.Reader, serverPrivateKey, cipherText, nil) + if err != nil { + return nil, err + } + + return plaintext, nil +} + +// RSAEncryptWithKey Encrypt using specified public key +func RSAEncryptWithKey(plainText []byte, publicKey *rsa.PublicKey) ([]byte, error) { + if publicKey == nil { + return nil, errors.New("public key is nil") + } + + hash := sha256.New() + ciphertext, err := rsa.EncryptOAEP(hash, rand.Reader, publicKey, plainText, nil) + if err != nil { + return nil, err + } + + return ciphertext, nil +} + +// RSADecryptWithKey Decrypt using specified private key +func RSADecryptWithKey(cipherText []byte, privateKey *rsa.PrivateKey) ([]byte, error) { + if privateKey == nil { + return nil, errors.New("private key is nil") + } + + hash := sha256.New() + plaintext, err := rsa.DecryptOAEP(hash, rand.Reader, privateKey, cipherText, nil) + if err != nil { + return nil, err + } + + return plaintext, nil +} + +// GetServerPublicKey Get server public key object +func GetServerPublicKey() (*rsa.PublicKey, error) { + if !initialized { + return nil, errors.New("RSA keys not initialized") + } + return serverPublicKey, nil +} + +// GetServerPrivateKey Get server private key object +func GetServerPrivateKey() (*rsa.PrivateKey, error) { + if !initialized { + return nil, errors.New("RSA keys not initialized") + } + return serverPrivateKey, nil +} + +// GetClientPublicKey Get client public key object +func GetClientPublicKey() (*rsa.PublicKey, error) { + if !initialized { + return nil, errors.New("RSA keys not initialized") + } + return clientPublicKey, nil +} + +// GetClientPrivateKey Get client private key object +func GetClientPrivateKey() (*rsa.PrivateKey, error) { + if !initialized { + return nil, errors.New("RSA keys not initialized") + } + return clientPrivateKey, nil +} + +// GetServerPublicKeyPEM Get server public key PEM format string +func GetServerPublicKeyPEM() (string, error) { + if !initialized { + return "", errors.New("RSA keys not initialized") + } + return g.Cfg().MustGet(gctx.New(), "rsa.server.public").String(), nil +} + +// GetClientPublicKeyPEM Get client public key PEM format string +func GetClientPublicKeyPEM() (string, error) { + if !initialized { + return "", errors.New("RSA keys not initialized") + } + return g.Cfg().MustGet(gctx.New(), "rsa.client.public").String(), nil +} + +// IsInitialized Check if RSA keys are initialized +func IsInitialized() bool { + return initialized +} + +func GenerateFourKeys() { + // Generate four key pairs and assign to global variables + //serverPrivateKey, serverPublicKey = generateKeyPair(2048) + clientPrivateKey, clientPublicKey = generateKeyPair(2048) + + // Print PEM + //fmt.Println("=== Server Private Key ===") + //fmt.Println(keyToPEM(serverPrivateKey, true)) + //fmt.Println("=== Server Public Key ===") + //fmt.Println(keyToPEM(serverPublicKey, false)) + + fmt.Println("=== Client Private Key ===") + fmt.Println(keyToPEM(clientPrivateKey, true)) + fmt.Println("=== Client Public Key ===") + fmt.Println(keyToPEM(clientPublicKey, false)) + + // Mark initialization complete + initialized = true +} + +func generateKeyPair(bits int) (*rsa.PrivateKey, *rsa.PublicKey) { + privateKey, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + panic(err) + } + return privateKey, &privateKey.PublicKey +} + +func keyToPEM(key interface{}, isPrivate bool) string { + if isPrivate { + privBytes := x509.MarshalPKCS1PrivateKey(key.(*rsa.PrivateKey)) + return string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privBytes, + })) + } else { + pubBytes, err := x509.MarshalPKIXPublicKey(key.(*rsa.PublicKey)) + if err != nil { + panic(err) + } + return string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubBytes, + })) + } +} + +// GenerateROSAKeyPair Quickly generate single RSA key pair (general method) +// bits: Key length, recommended 2048/4096 +// Return: private key PEM string, public key PEM string, error message +func GenerateROSAKeyPair(bits int) (privateKeyPEM, publicKeyPEM string, err error) { + privateKey, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return "", "", err + } + privBytes := x509.MarshalPKCS1PrivateKey(privateKey) + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privBytes, + }) + if privPEM == nil { + return "", "", errors.New("failed to encode private key") + } + pubBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return "", "", err + } + pubPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubBytes, + }) + if pubPEM == nil { + return "", "", errors.New("failed to encode public key") + } + return string(privPEM), string(pubPEM), nil +}