diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0bebc9b..6fc2fdd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,32 +4,3 @@ # Default owner * @husamettinarabaci -# Module-specific owners - -## Firmware -/firmware/ @husamettinarabaci - -## Hardware -/hardware/ @husamettinarabaci - -## Mechanic -/mechanic/ @husamettinarabaci - -## Documentation -/docs/ @husamettinarabaci - -## Tests -/integration-tests/ @husamettinarabaci - -## Benchmarks -/benchmarks/ @husamettinarabaci - -## Examples -/examples/ @husamettinarabaci - -## Scripts -/scripts/ @husamettinarabaci - -## GitHub -/.github/ @husamettinarabaci - diff --git a/.github/workflows/auto-add-project.yml b/.github/workflows/auto-add-project.yml new file mode 100644 index 0000000..af93d5b --- /dev/null +++ b/.github/workflows/auto-add-project.yml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2025 hexaTune LLC +# SPDX-License-Identifier: MIT + +name: Auto add issues and PRs to hTuneSys Project 16 + +on: + issues: + types: [opened] + pull_request: + types: [opened] + +jobs: + add-to-project: + runs-on: ubuntu-latest + + steps: + - uses: actions/add-to-project@v0.5.0 + with: + project-url: https://github.com/orgs/hTuneSys/projects/16 + github-token: ${{ secrets.ORG_PROJECT_TOKEN }} diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 91ca35e..c1a08b2 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -15,7 +15,7 @@ permissions: jobs: commitlint: - name: Commit Lint (hexaTuneWeb) + name: Commit Lint (hexaGenApp) runs-on: ubuntu-latest steps: - name: Checkout (full history) diff --git a/hexagenapp/android/app/build.gradle.kts b/hexagenapp/android/app/build.gradle.kts index 10d8394..6dfebc8 100644 --- a/hexagenapp/android/app/build.gradle.kts +++ b/hexagenapp/android/app/build.gradle.kts @@ -68,5 +68,5 @@ flutter { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } diff --git a/hexagenapp/android/app/src/main/jniLibs/arm64-v8a/libhexa_tune_proto_ffi.so b/hexagenapp/android/app/src/main/jniLibs/arm64-v8a/libhexa_tune_proto_ffi.so new file mode 100644 index 0000000..af6347e Binary files /dev/null and b/hexagenapp/android/app/src/main/jniLibs/arm64-v8a/libhexa_tune_proto_ffi.so differ diff --git a/hexagenapp/android/app/src/main/jniLibs/armeabi-v7a/libhexa_tune_proto_ffi.so b/hexagenapp/android/app/src/main/jniLibs/armeabi-v7a/libhexa_tune_proto_ffi.so new file mode 100644 index 0000000..07bc37d Binary files /dev/null and b/hexagenapp/android/app/src/main/jniLibs/armeabi-v7a/libhexa_tune_proto_ffi.so differ diff --git a/hexagenapp/android/app/src/main/jniLibs/x86_64/libhexa_tune_proto_ffi.so b/hexagenapp/android/app/src/main/jniLibs/x86_64/libhexa_tune_proto_ffi.so new file mode 100644 index 0000000..b2bbf57 Binary files /dev/null and b/hexagenapp/android/app/src/main/jniLibs/x86_64/libhexa_tune_proto_ffi.so differ diff --git a/hexagenapp/android/gradle.properties b/hexagenapp/android/gradle.properties index f018a61..1ca6728 100644 --- a/hexagenapp/android/gradle.properties +++ b/hexagenapp/android/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.java.home=/usr/lib/jvm/java-21-openjdk android.useAndroidX=true android.enableJetifier=true diff --git a/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/Info.plist b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/Info.plist new file mode 100644 index 0000000..f126d4a --- /dev/null +++ b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/Info.plist @@ -0,0 +1,47 @@ + + + + + AvailableLibraries + + + BinaryPath + libhexa_tune_proto_ffi.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64-simulator + LibraryPath + libhexa_tune_proto_ffi.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + BinaryPath + libhexa_tune_proto_ffi.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64 + LibraryPath + libhexa_tune_proto_ffi.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64-simulator/Headers/hexa_tune_proto.h b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64-simulator/Headers/hexa_tune_proto.h new file mode 100644 index 0000000..797f4b9 --- /dev/null +++ b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64-simulator/Headers/hexa_tune_proto.h @@ -0,0 +1,154 @@ +/* Generated by cbindgen — do not edit manually */ + +#ifndef HEXA_TUNE_PROTO_H +#define HEXA_TUNE_PROTO_H + +#include +#include +#include +#include + +/** + * A byte slice descriptor for FFI (pointer + length). + */ +typedef struct HtpSlice { + /** + * Pointer to the data. + */ + const uint8_t *ptr; + /** + * Length of the data. + */ + uintptr_t len; +} HtpSlice; + +/** + * Parse result returned by `htp_at_parse`. + */ +typedef struct HtpAtParseResult { + /** + * Parsed command ID. + */ + uint32_t id; + /** + * Operation type: 0=Set, 1=Query, 2=Response. + */ + int32_t op; + /** + * Offset of command name within the input buffer. + */ + uintptr_t name_offset; + /** + * Length of command name. + */ + uintptr_t name_len; + /** + * Number of parameters (max 8). + */ + uintptr_t param_count; + /** + * Offsets of each parameter within the input buffer. + */ + uintptr_t param_offsets[8]; + /** + * Lengths of each parameter. + */ + uintptr_t param_lens[8]; +} HtpAtParseResult; + +/** + * Encodes an AT command string into the output buffer. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_at_encode(const uint8_t *name_ptr, + uintptr_t name_len, + uint32_t id, + int32_t op, + const struct HtpSlice *params_ptr, + uintptr_t params_count, + uint8_t *out_ptr, + uintptr_t out_cap, + uintptr_t *out_len); + +/** + * Parses an AT command from a byte buffer. + * + * On success, populates the `HtpAtParseResult` struct with offsets into the input buffer. + * + * # Safety + * All pointers must be valid. + */ +int32_t htp_at_parse(const uint8_t *input_ptr, + uintptr_t input_len, + struct HtpAtParseResult *result); + +/** + * Frames payload bytes into a SysEx message. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_sysex_frame(const uint8_t *payload_ptr, + uintptr_t payload_len, + uint8_t *out_ptr, + uintptr_t out_cap, + uintptr_t *out_len); + +/** + * Extracts payload from a SysEx message. + * + * # Safety + * All pointers must be valid. + */ +int32_t htp_sysex_unframe(const uint8_t *data_ptr, + uintptr_t data_len, + uintptr_t *out_offset, + uintptr_t *out_len); + +/** + * Converts SysEx bytes to USB MIDI 4-byte packets. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_usb_packetize(const uint8_t *sysex_ptr, + uintptr_t sysex_len, + uint8_t (*out_ptr)[4], + uintptr_t out_cap, + uintptr_t *out_count); + +/** + * Reassembles SysEx bytes from USB MIDI 4-byte packets. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_usb_depacketize(const uint8_t (*packets_ptr)[4], + uintptr_t packet_count, + uint8_t *out_ptr, + uintptr_t out_cap, + uintptr_t *out_len); + +/** + * Full pipeline: encode AT command → SysEx → USB MIDI packets. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_encode_to_packets(const uint8_t *name_ptr, + uintptr_t name_len, + uint32_t id, + int32_t op, + const struct HtpSlice *params_ptr, + uintptr_t params_count, + uint8_t *at_buf_ptr, + uintptr_t at_buf_cap, + uint8_t *sysex_buf_ptr, + uintptr_t sysex_buf_cap, + uint8_t (*packets_out_ptr)[4], + uintptr_t packets_out_cap, + uintptr_t *packets_out_count); + +#endif /* HEXA_TUNE_PROTO_H */ diff --git a/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64-simulator/libhexa_tune_proto_ffi.a b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64-simulator/libhexa_tune_proto_ffi.a new file mode 100644 index 0000000..0f5ef04 Binary files /dev/null and b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64-simulator/libhexa_tune_proto_ffi.a differ diff --git a/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64/Headers/hexa_tune_proto.h b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64/Headers/hexa_tune_proto.h new file mode 100644 index 0000000..797f4b9 --- /dev/null +++ b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64/Headers/hexa_tune_proto.h @@ -0,0 +1,154 @@ +/* Generated by cbindgen — do not edit manually */ + +#ifndef HEXA_TUNE_PROTO_H +#define HEXA_TUNE_PROTO_H + +#include +#include +#include +#include + +/** + * A byte slice descriptor for FFI (pointer + length). + */ +typedef struct HtpSlice { + /** + * Pointer to the data. + */ + const uint8_t *ptr; + /** + * Length of the data. + */ + uintptr_t len; +} HtpSlice; + +/** + * Parse result returned by `htp_at_parse`. + */ +typedef struct HtpAtParseResult { + /** + * Parsed command ID. + */ + uint32_t id; + /** + * Operation type: 0=Set, 1=Query, 2=Response. + */ + int32_t op; + /** + * Offset of command name within the input buffer. + */ + uintptr_t name_offset; + /** + * Length of command name. + */ + uintptr_t name_len; + /** + * Number of parameters (max 8). + */ + uintptr_t param_count; + /** + * Offsets of each parameter within the input buffer. + */ + uintptr_t param_offsets[8]; + /** + * Lengths of each parameter. + */ + uintptr_t param_lens[8]; +} HtpAtParseResult; + +/** + * Encodes an AT command string into the output buffer. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_at_encode(const uint8_t *name_ptr, + uintptr_t name_len, + uint32_t id, + int32_t op, + const struct HtpSlice *params_ptr, + uintptr_t params_count, + uint8_t *out_ptr, + uintptr_t out_cap, + uintptr_t *out_len); + +/** + * Parses an AT command from a byte buffer. + * + * On success, populates the `HtpAtParseResult` struct with offsets into the input buffer. + * + * # Safety + * All pointers must be valid. + */ +int32_t htp_at_parse(const uint8_t *input_ptr, + uintptr_t input_len, + struct HtpAtParseResult *result); + +/** + * Frames payload bytes into a SysEx message. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_sysex_frame(const uint8_t *payload_ptr, + uintptr_t payload_len, + uint8_t *out_ptr, + uintptr_t out_cap, + uintptr_t *out_len); + +/** + * Extracts payload from a SysEx message. + * + * # Safety + * All pointers must be valid. + */ +int32_t htp_sysex_unframe(const uint8_t *data_ptr, + uintptr_t data_len, + uintptr_t *out_offset, + uintptr_t *out_len); + +/** + * Converts SysEx bytes to USB MIDI 4-byte packets. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_usb_packetize(const uint8_t *sysex_ptr, + uintptr_t sysex_len, + uint8_t (*out_ptr)[4], + uintptr_t out_cap, + uintptr_t *out_count); + +/** + * Reassembles SysEx bytes from USB MIDI 4-byte packets. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_usb_depacketize(const uint8_t (*packets_ptr)[4], + uintptr_t packet_count, + uint8_t *out_ptr, + uintptr_t out_cap, + uintptr_t *out_len); + +/** + * Full pipeline: encode AT command → SysEx → USB MIDI packets. + * + * # Safety + * All pointers must be valid for their respective lengths. + */ +int32_t htp_encode_to_packets(const uint8_t *name_ptr, + uintptr_t name_len, + uint32_t id, + int32_t op, + const struct HtpSlice *params_ptr, + uintptr_t params_count, + uint8_t *at_buf_ptr, + uintptr_t at_buf_cap, + uint8_t *sysex_buf_ptr, + uintptr_t sysex_buf_cap, + uint8_t (*packets_out_ptr)[4], + uintptr_t packets_out_cap, + uintptr_t *packets_out_count); + +#endif /* HEXA_TUNE_PROTO_H */ diff --git a/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64/libhexa_tune_proto_ffi.a b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64/libhexa_tune_proto_ffi.a new file mode 100644 index 0000000..8cea9b8 Binary files /dev/null and b/hexagenapp/ios/Frameworks/HexaTuneProto.xcframework/ios-arm64/libhexa_tune_proto_ffi.a differ diff --git a/hexagenapp/lib/l10n/app_en.arb b/hexagenapp/lib/l10n/app_en.arb index 7dcd5d5..be6c6ae 100644 --- a/hexagenapp/lib/l10n/app_en.arb +++ b/hexagenapp/lib/l10n/app_en.arb @@ -92,6 +92,10 @@ "themeLight": "Light", "themeDark": "Dark", + "simulationMode": "Simulation Mode", + "simulationModeDescription": "Test the app without a physical device", + "simulationModeActive": "Simulation mode is active - no device required", + "operationCompletedSuccessfully": "Generation completed successfully", "operationStoppedByUser": "Operation stopped by user", "operationFailedWithErrors": "Operation failed with errors", diff --git a/hexagenapp/lib/l10n/app_localizations.dart b/hexagenapp/lib/l10n/app_localizations.dart index 8d79759..017ad4f 100644 --- a/hexagenapp/lib/l10n/app_localizations.dart +++ b/hexagenapp/lib/l10n/app_localizations.dart @@ -404,6 +404,24 @@ abstract class AppLocalizations { /// **'Dark'** String get themeDark; + /// No description provided for @simulationMode. + /// + /// In en, this message translates to: + /// **'Simulation Mode'** + String get simulationMode; + + /// No description provided for @simulationModeDescription. + /// + /// In en, this message translates to: + /// **'Test the app without a physical device'** + String get simulationModeDescription; + + /// No description provided for @simulationModeActive. + /// + /// In en, this message translates to: + /// **'Simulation mode is active - no device required'** + String get simulationModeActive; + /// No description provided for @operationCompletedSuccessfully. /// /// In en, this message translates to: diff --git a/hexagenapp/lib/l10n/app_localizations_en.dart b/hexagenapp/lib/l10n/app_localizations_en.dart index b9ab92e..1a6d01c 100644 --- a/hexagenapp/lib/l10n/app_localizations_en.dart +++ b/hexagenapp/lib/l10n/app_localizations_en.dart @@ -174,6 +174,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get themeDark => 'Dark'; + @override + String get simulationMode => 'Simulation Mode'; + + @override + String get simulationModeDescription => + 'Test the app without a physical device'; + + @override + String get simulationModeActive => + 'Simulation mode is active - no device required'; + @override String get operationCompletedSuccessfully => 'Generation completed successfully'; diff --git a/hexagenapp/lib/l10n/app_localizations_tr.dart b/hexagenapp/lib/l10n/app_localizations_tr.dart index fe1907e..d8cf4f8 100644 --- a/hexagenapp/lib/l10n/app_localizations_tr.dart +++ b/hexagenapp/lib/l10n/app_localizations_tr.dart @@ -174,6 +174,16 @@ class AppLocalizationsTr extends AppLocalizations { @override String get themeDark => 'Koyu'; + @override + String get simulationMode => 'Simülasyon Modu'; + + @override + String get simulationModeDescription => + 'Uygulamayı fiziksel cihaz olmadan test edin'; + + @override + String get simulationModeActive => 'Simülasyon modu aktif - cihaz gerekmiyor'; + @override String get operationCompletedSuccessfully => 'Üretim başarıyla tamamlandı'; diff --git a/hexagenapp/lib/l10n/app_tr.arb b/hexagenapp/lib/l10n/app_tr.arb index 64f18f7..f97c977 100644 --- a/hexagenapp/lib/l10n/app_tr.arb +++ b/hexagenapp/lib/l10n/app_tr.arb @@ -92,6 +92,10 @@ "themeLight": "Açık", "themeDark": "Koyu", + "simulationMode": "Simülasyon Modu", + "simulationModeDescription": "Uygulamayı fiziksel cihaz olmadan test edin", + "simulationModeActive": "Simülasyon modu aktif - cihaz gerekmiyor", + "operationCompletedSuccessfully": "Üretim başarıyla tamamlandı", "operationStoppedByUser": "İşlem kullanıcı tarafından durduruldu", "operationFailedWithErrors": "İşlem hatalarla başarısız oldu", diff --git a/hexagenapp/lib/src/core/at/at.dart b/hexagenapp/lib/src/core/at/at.dart deleted file mode 100644 index 79b5c02..0000000 --- a/hexagenapp/lib/src/core/at/at.dart +++ /dev/null @@ -1,255 +0,0 @@ -// SPDX-FileCopyrightText: 2025 hexaTune LLC -// SPDX-License-Identifier: MIT - -import 'dart:typed_data'; -import 'package:hexagenapp/src/core/sysex/sysex.dart'; - -/// AT Command types -enum ATCommandType { version, freq, setRgb, reset, fwUpdate, operation } - -/// AT Command class for building commands generically -class ATCommand { - final int id; - final ATCommandType type; - final List params; - final bool isQuery; - - ATCommand(this.id, this.type, this.params, {this.isQuery = false}); - - /// Compile the command to string - /// Query format: AT+COMMAND? - /// Command with params: AT+COMMAND=id#PARAM1#PARAM2... - /// Command without params: AT+COMMAND=id - String compile() { - final name = type.name.toUpperCase(); - - if (isQuery) { - // Query format: AT+COMMAND? - return 'AT+$name?'; - } else if (params.isEmpty) { - // Command without params: AT+COMMAND=id - return 'AT+$name=$id'; - } else { - // Command with params: AT+COMMAND=id#PARAM1#PARAM2... - final paramStr = [id.toString(), ...params].join('#'); - return 'AT+$name=$paramStr'; - } - } - - /// Build as SysEx bytes - Uint8List buildSysEx() { - return SysEx.buildSysEx(compile()); - } - - /// Factory for version query - factory ATCommand.version() { - return ATCommand(0, ATCommandType.version, [], isQuery: true); - } - - /// Factory for operation query (AT+OPERATION?) - factory ATCommand.operationQuery() { - return ATCommand(0, ATCommandType.operation, [], isQuery: true); - } - - /// Factory for operation prepare (AT+OPERATION=id#PREPARE) - factory ATCommand.operationPrepare(int id) { - return ATCommand(id, ATCommandType.operation, ['PREPARE']); - } - - /// Factory for operation generate (AT+OPERATION=id#GENERATE) - factory ATCommand.operationGenerate(int id) { - return ATCommand(id, ATCommandType.operation, ['GENERATE']); - } - - /// Factory for freq command - factory ATCommand.freq(int id, int freq, int timeMs) { - return ATCommand(id, ATCommandType.freq, [ - freq.toString(), - timeMs.toString(), - ]); - } - - /// Factory for setRgb command - factory ATCommand.setRgb(int id, int r, int g, int b) { - return ATCommand(id, ATCommandType.setRgb, [ - r.toString(), - g.toString(), - b.toString(), - ]); - } - - /// Factory for reset command (AT+RESET=id) - factory ATCommand.reset(int id) { - return ATCommand(id, ATCommandType.reset, []); - } - - /// Factory for fwUpdate command (AT+FWUPDATE=id) - factory ATCommand.fwUpdate(int id) { - return ATCommand(id, ATCommandType.fwUpdate, []); - } -} - -/// Extract and parse AT response from MIDI data -ATResponse? extractAndParseATResponse(Uint8List data) { - final message = SysEx.extractSysexPayload(data); - if (message == null) return null; - return parseATResponse(message); -} - -/// Parse AT response -/// Formats: -/// AT+VERSION=0#version -/// AT+ERROR=id#error_code -/// AT+DONE=id (firmware has DONE response, but no DONE command) -/// AT+STATUS=0#AVAILABLE or GENERATING -/// AT+OPERATION=id#PREPARE#COMPLETED -/// AT+OPERATION=id#GENERATE#COMPLETED -/// AT+OPERATION=id#GENERATING#stepId -/// AT+FREQ=stepId#freq#timeMs#COMPLETED -ATResponse? parseATResponse(String message) { - final trimmed = message.trim(); - if (!trimmed.startsWith('AT+')) return null; - - final cmd = trimmed.substring(3); - final eqPos = cmd.indexOf('='); - if (eqPos == -1) return null; - - final name = cmd.substring(0, eqPos); - final paramStr = cmd.substring(eqPos + 1); - final parts = paramStr.split('#'); - if (parts.isEmpty) return null; - - final id = parts[0]; - final params = parts.length > 1 ? parts.sublist(1) : []; - - switch (name) { - case 'VERSION': - return ATResponse(type: ATResponseType.version, id: id, params: params); - case 'OPERATION': - return ATResponse(type: ATResponseType.operation, id: id, params: params); - case 'FREQ': - return ATResponse(type: ATResponseType.freq, id: id, params: params); - case 'SETRGB': - return ATResponse( - type: ATResponseType.done, - id: id, - params: params, - ); // Assuming DONE for SETRGB - case 'RESET': - return ATResponse( - type: ATResponseType.done, - id: id, - params: params, - ); // Assuming DONE for RESET - case 'FWUPDATE': - return ATResponse( - type: ATResponseType.done, - id: id, - params: params, - ); // Assuming DONE for FWUPDATE - case 'ERROR': - return ATResponse(type: ATResponseType.error, id: id, params: params); - case 'DONE': - return ATResponse(type: ATResponseType.done, id: id, params: params); - case 'STATUS': - return ATResponse(type: ATResponseType.status, id: id, params: params); - default: - return null; - } -} - -/// AT Response types -enum ATResponseType { version, error, done, status, operation, freq } - -/// Device status from periodic STATUS messages -enum DeviceStatus { available, generating } - -/// Command status for tracking -enum CommandStatus { pending, success, error, timeout } - -/// Sent command tracking -class SentCommand { - final int id; - final String command; - final DateTime sentAt; - CommandStatus status; - String? errorCode; - - SentCommand( - this.id, - this.command, - this.sentAt, - this.status, { - this.errorCode, - }); -} - -/// Notification item -class NotificationItem { - final String message; - final DateTime time; - bool read; - - NotificationItem(this.message, this.time, {this.read = false}); -} - -/// AT Response data -class ATResponse { - final ATResponseType type; - final String id; - final List params; - - ATResponse({required this.type, required this.id, required this.params}); - - /// Convenience getter for version response - String get version => params.isNotEmpty ? params[0] : ''; - - /// Convenience getter for error code - String get errorCode => params.isNotEmpty ? params[0] : ''; - - /// Convenience getter for device status - DeviceStatus get status => params.isNotEmpty && params[0] == 'AVAILABLE' - ? DeviceStatus.available - : DeviceStatus.generating; - - /// Operation status getter - /// Returns the actual operation status based on response format: - /// - AT+OPERATION=id#PREPARE#COMPLETED -> returns "COMPLETED" - /// - AT+OPERATION=id#GENERATE#COMPLETED -> returns "COMPLETED" - /// - AT+OPERATION=id#GENERATING#stepId -> returns "GENERATING" - String get operationStatus { - if (params.isEmpty) return ''; - - // If second param is COMPLETED, that's the status - if (params.length > 1 && params[1] == 'COMPLETED') { - return 'COMPLETED'; - } - - // Otherwise return first param (PREPARE, GENERATE, GENERATING, etc.) - return params[0]; - } - - /// Step ID for GENERATING status (AT+OPERATION=id#GENERATING#stepId) - int? get operationStepId { - if (params.length > 1 && params[0] == 'GENERATING') { - return int.tryParse(params[1]); - } - return null; - } - - /// Check if FREQ command completed (AT+FREQ=stepId#freq#timeMs#COMPLETED) - bool get freqCompleted { - if (type == ATResponseType.freq && params.length >= 3) { - return params[2] == 'COMPLETED'; - } - return false; - } - - /// Check if OPERATION command completed - bool get operationCompleted { - if (type == ATResponseType.operation && params.isNotEmpty) { - return params.contains('COMPLETED'); - } - return false; - } -} diff --git a/hexagenapp/lib/src/core/device/device.dart b/hexagenapp/lib/src/core/device/device.dart index 1b0d658..60cb9b7 100644 --- a/hexagenapp/lib/src/core/device/device.dart +++ b/hexagenapp/lib/src/core/device/device.dart @@ -6,8 +6,8 @@ import 'dart:typed_data'; import 'package:flutter_midi_command/flutter_midi_command.dart'; import 'package:flutter/services.dart' show PlatformException; import 'package:hexagenapp/src/core/error/error.dart'; -import 'package:hexagenapp/src/core/at/at.dart'; -import 'package:hexagenapp/src/core/sysex/sysex.dart'; +import 'package:hexagenapp/src/core/proto/at_command.dart'; +import 'package:hexagenapp/src/core/proto/at_response.dart'; import 'package:hexagenapp/src/core/service/log_service.dart'; /// Device response callback @@ -227,33 +227,24 @@ class HexaTuneDeviceManager { try { final bufferedData = Uint8List.fromList(_sysexBuffer); - final message = SysEx.extractSysexPayload(bufferedData); // Clear the buffer _sysexBuffer.clear(); - if (message == null) { + final response = extractAndParseATResponse(bufferedData); + + if (response == null) { logger.warning( - 'Failed to extract SysEx payload', + 'Failed to parse AT response from SysEx data', category: LogCategory.midi, ); _waitingForResponse = false; return; } - logger.midi('Decoded AT response: "$message"'); + logger.midi('Decoded AT response: type=${response.type}, id=${response.id}'); _waitingForResponse = false; - final response = parseATResponse(message); - - if (response == null) { - logger.warning( - 'Unknown AT response format: "$message"', - category: LogCategory.midi, - ); - return; - } - switch (response.type) { case ATResponseType.error: logger.warning( diff --git a/hexagenapp/lib/src/core/proto/at_command.dart b/hexagenapp/lib/src/core/proto/at_command.dart new file mode 100644 index 0000000..cc5235f --- /dev/null +++ b/hexagenapp/lib/src/core/proto/at_command.dart @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +import 'dart:typed_data'; +import 'proto_service.dart'; +import 'hexa_tune_proto_ffi.dart'; + +/// AT Command types +enum ATCommandType { version, freq, setRgb, reset, fwUpdate, operation } + +/// AT Command class for building commands generically. +/// +/// Drop-in replacement for the old Dart-only ATCommand. +/// Encoding is now performed via FFI to the Rust hexaTuneProto library. +class ATCommand { + final int id; + final ATCommandType type; + final List params; + final bool isQuery; + + ATCommand(this.id, this.type, this.params, {this.isQuery = false}); + + /// Compile the command to string (for logging/debug). + /// Query format: AT+COMMAND? + /// Command with params: AT+COMMAND=id#PARAM1#PARAM2... + /// Command without params: AT+COMMAND=id + String compile() { + final name = type.name.toUpperCase(); + + if (isQuery) { + return 'AT+$name?'; + } else if (params.isEmpty) { + return 'AT+$name=$id'; + } else { + final paramStr = [id.toString(), ...params].join('#'); + return 'AT+$name=$paramStr'; + } + } + + /// Build as SysEx bytes via FFI (F0...F7) — for flutter_midi_command sendData + Uint8List buildSysEx() { + final proto = ProtoService().proto; + final name = type.name.toUpperCase(); + final atBytes = proto.atEncode( + name, + id: id, + op: isQuery ? AtOp.query : AtOp.set, + params: params, + ); + return proto.sysexFrame(atBytes); + } + + /// Build as USB MIDI packets via FFI (full pipeline) + Uint8List buildPackets() { + final proto = ProtoService().proto; + final name = type.name.toUpperCase(); + return proto.encodeToPackets( + name, + id: id, + op: isQuery ? AtOp.query : AtOp.set, + params: params, + ); + } + + /// Factory for version query + factory ATCommand.version() { + return ATCommand(0, ATCommandType.version, [], isQuery: true); + } + + /// Factory for operation query (AT+OPERATION?) + factory ATCommand.operationQuery() { + return ATCommand(0, ATCommandType.operation, [], isQuery: true); + } + + /// Factory for operation prepare (AT+OPERATION=id#PREPARE) + factory ATCommand.operationPrepare(int id) { + return ATCommand(id, ATCommandType.operation, ['PREPARE']); + } + + /// Factory for operation generate (AT+OPERATION=id#GENERATE) + factory ATCommand.operationGenerate(int id) { + return ATCommand(id, ATCommandType.operation, ['GENERATE']); + } + + /// Factory for freq command + factory ATCommand.freq(int id, int freq, int timeMs) { + return ATCommand(id, ATCommandType.freq, [ + freq.toString(), + timeMs.toString(), + ]); + } + + /// Factory for setRgb command + factory ATCommand.setRgb(int id, int r, int g, int b) { + return ATCommand(id, ATCommandType.setRgb, [ + r.toString(), + g.toString(), + b.toString(), + ]); + } + + /// Factory for reset command (AT+RESET=id) + factory ATCommand.reset(int id) { + return ATCommand(id, ATCommandType.reset, []); + } + + /// Factory for fwUpdate command (AT+FWUPDATE=id) + factory ATCommand.fwUpdate(int id) { + return ATCommand(id, ATCommandType.fwUpdate, []); + } +} diff --git a/hexagenapp/lib/src/core/proto/at_response.dart b/hexagenapp/lib/src/core/proto/at_response.dart new file mode 100644 index 0000000..efc3cf8 --- /dev/null +++ b/hexagenapp/lib/src/core/proto/at_response.dart @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +import 'dart:typed_data'; +import 'proto_service.dart'; +import 'hexa_tune_proto_ffi.dart'; + +/// AT Response types +enum ATResponseType { version, error, done, status, operation, freq } + +/// Device status from periodic STATUS messages +enum DeviceStatus { available, generating } + +/// Command status for tracking +enum CommandStatus { pending, success, error, timeout } + +/// Sent command tracking +class SentCommand { + final int id; + final String command; + final DateTime sentAt; + CommandStatus status; + String? errorCode; + + SentCommand( + this.id, + this.command, + this.sentAt, + this.status, { + this.errorCode, + }); +} + +/// Notification item +class NotificationItem { + final String message; + final DateTime time; + bool read; + + NotificationItem(this.message, this.time, {this.read = false}); +} + +/// AT Response data +class ATResponse { + final ATResponseType type; + final String id; + final List params; + + ATResponse({required this.type, required this.id, required this.params}); + + /// Convenience getter for version response + String get version => params.isNotEmpty ? params[0] : ''; + + /// Convenience getter for error code + String get errorCode => params.isNotEmpty ? params[0] : ''; + + /// Convenience getter for device status + DeviceStatus get status => params.isNotEmpty && params[0] == 'AVAILABLE' + ? DeviceStatus.available + : DeviceStatus.generating; + + /// Operation status getter + /// Returns the actual operation status based on response format: + /// - AT+OPERATION=id#PREPARE#COMPLETED -> returns "COMPLETED" + /// - AT+OPERATION=id#GENERATE#COMPLETED -> returns "COMPLETED" + /// - AT+OPERATION=id#GENERATING#stepId -> returns "GENERATING" + String get operationStatus { + if (params.isEmpty) return ''; + + // If second param is COMPLETED, that's the status + if (params.length > 1 && params[1] == 'COMPLETED') { + return 'COMPLETED'; + } + + // Otherwise return first param (PREPARE, GENERATE, GENERATING, etc.) + return params[0]; + } + + /// Step ID for GENERATING status (AT+OPERATION=id#GENERATING#stepId) + int? get operationStepId { + if (params.length > 1 && params[0] == 'GENERATING') { + return int.tryParse(params[1]); + } + return null; + } + + /// Check if FREQ command completed (AT+FREQ=stepId#freq#timeMs#COMPLETED) + bool get freqCompleted { + if (type == ATResponseType.freq && params.length >= 3) { + return params[2] == 'COMPLETED'; + } + return false; + } + + /// Check if OPERATION command completed + bool get operationCompleted { + if (type == ATResponseType.operation && params.isNotEmpty) { + return params.contains('COMPLETED'); + } + return false; + } +} + +/// Parse AT response using FFI. +/// +/// Drop-in replacement for the old Dart-only parseATResponse. +ATResponse? parseATResponse(String message) { + final trimmed = message.trim(); + if (!trimmed.startsWith('AT+')) return null; + + try { + final proto = ProtoService().proto; + final input = Uint8List.fromList(trimmed.codeUnits); + final result = proto.atParse(input); + + final id = result.id.toString(); + final params = result.params; + + switch (result.name) { + case 'VERSION': + return ATResponse(type: ATResponseType.version, id: id, params: params); + case 'OPERATION': + return ATResponse( + type: ATResponseType.operation, + id: id, + params: params, + ); + case 'FREQ': + return ATResponse(type: ATResponseType.freq, id: id, params: params); + case 'SETRGB': + return ATResponse(type: ATResponseType.done, id: id, params: params); + case 'RESET': + return ATResponse(type: ATResponseType.done, id: id, params: params); + case 'FWUPDATE': + return ATResponse(type: ATResponseType.done, id: id, params: params); + case 'ERROR': + return ATResponse(type: ATResponseType.error, id: id, params: params); + case 'DONE': + return ATResponse(type: ATResponseType.done, id: id, params: params); + case 'STATUS': + return ATResponse(type: ATResponseType.status, id: id, params: params); + default: + return null; + } + } on HexaTuneProtoError { + return null; + } catch (_) { + return null; + } +} + +/// Extract SysEx payload from MIDI data and parse AT response. +/// +/// Handles both USB MIDI packet format (Android) and raw SysEx (iOS). +ATResponse? extractAndParseATResponse(Uint8List data) { + try { + final proto = ProtoService().proto; + + // Detect USB MIDI packet format vs raw SysEx + final isUsbMidiPackets = + data.length >= 4 && (data[0] & 0xF0) == 0x00 && data.length % 4 == 0; + + Uint8List payload; + if (isUsbMidiPackets) { + // Android: USB MIDI packets → depacketize → unframe + final sysex = proto.usbDepacketize(data); + payload = proto.sysexUnframe(sysex); + } else { + // iOS: Raw SysEx bytes → unframe directly + payload = proto.sysexUnframe(data); + } + + final message = String.fromCharCodes(payload); + return parseATResponse(message); + } on HexaTuneProtoError { + return null; + } catch (_) { + return null; + } +} diff --git a/hexagenapp/lib/src/core/proto/hexa_tune_proto_ffi.dart b/hexagenapp/lib/src/core/proto/hexa_tune_proto_ffi.dart new file mode 100644 index 0000000..eea0528 --- /dev/null +++ b/hexagenapp/lib/src/core/proto/hexa_tune_proto_ffi.dart @@ -0,0 +1,577 @@ +// SPDX-FileCopyrightText: 2026 hexaTune LLC +// SPDX-License-Identifier: MIT + +/// Dart FFI bindings for hexaTuneProto. +/// +/// This file provides a type-safe Dart wrapper around the C ABI exported +/// by `hexa-tune-proto-ffi`. All protocol encoding/decoding is performed +/// in Rust — Dart only calls through FFI. +/// +/// Usage: +/// ```dart +/// final proto = HexaTuneProto('libhexa_tune_proto_ffi.so'); +/// final packets = proto.encodeToPackets('FREQ', id: 5, params: ['440', '1000']); +/// final response = proto.parseAt(responseBytes); +/// ``` +library hexa_tune_proto_ffi; + +import 'dart:ffi'; +import 'dart:typed_data'; +import 'package:ffi/ffi.dart'; + +// --------------------------------------------------------------------------- +// Native struct definitions (mirror C structs) +// --------------------------------------------------------------------------- + +/// Mirrors `HtpSlice` — a pointer + length pair. +final class HtpSlice extends Struct { + external Pointer ptr; + + @IntPtr() + external int len; +} + +/// Mirrors `HtpAtParseResult` — parse output with offsets into input buffer. +final class HtpAtParseResult extends Struct { + @Uint32() + external int id; + + @Int32() + external int op; + + @IntPtr() + external int nameOffset; + + @IntPtr() + external int nameLen; + + @IntPtr() + external int paramCount; + + @Array(8) + external Array paramOffsets; + + @Array(8) + external Array paramLens; +} + +// --------------------------------------------------------------------------- +// Native function typedefs +// --------------------------------------------------------------------------- + +typedef _HtpAtEncodeNative = Int32 Function( + Pointer namePtr, + IntPtr nameLen, + Uint32 id, + Int32 op, + Pointer paramsPtr, + IntPtr paramsCount, + Pointer outPtr, + IntPtr outCap, + Pointer outLen, +); +typedef _HtpAtEncodeDart = int Function( + Pointer namePtr, + int nameLen, + int id, + int op, + Pointer paramsPtr, + int paramsCount, + Pointer outPtr, + int outCap, + Pointer outLen, +); + +typedef _HtpAtParseNative = Int32 Function( + Pointer inputPtr, + IntPtr inputLen, + Pointer result, +); +typedef _HtpAtParseDart = int Function( + Pointer inputPtr, + int inputLen, + Pointer result, +); + +typedef _HtpSysexFrameNative = Int32 Function( + Pointer payloadPtr, + IntPtr payloadLen, + Pointer outPtr, + IntPtr outCap, + Pointer outLen, +); +typedef _HtpSysexFrameDart = int Function( + Pointer payloadPtr, + int payloadLen, + Pointer outPtr, + int outCap, + Pointer outLen, +); + +typedef _HtpSysexUnframeNative = Int32 Function( + Pointer dataPtr, + IntPtr dataLen, + Pointer outOffset, + Pointer outLen, +); +typedef _HtpSysexUnframeDart = int Function( + Pointer dataPtr, + int dataLen, + Pointer outOffset, + Pointer outLen, +); + +typedef _HtpUsbPacketizeNative = Int32 Function( + Pointer sysexPtr, + IntPtr sysexLen, + Pointer outPtr, // Pointer to [u8;4] array treated as flat bytes + IntPtr outCap, + Pointer outCount, +); +typedef _HtpUsbPacketizeDart = int Function( + Pointer sysexPtr, + int sysexLen, + Pointer outPtr, + int outCap, + Pointer outCount, +); + +typedef _HtpUsbDepacketizeNative = Int32 Function( + Pointer packetsPtr, // flat [u8;4] × N + IntPtr packetCount, + Pointer outPtr, + IntPtr outCap, + Pointer outLen, +); +typedef _HtpUsbDepacketizeDart = int Function( + Pointer packetsPtr, + int packetCount, + Pointer outPtr, + int outCap, + Pointer outLen, +); + +typedef _HtpEncodeToPacketsNative = Int32 Function( + Pointer namePtr, + IntPtr nameLen, + Uint32 id, + Int32 op, + Pointer paramsPtr, + IntPtr paramsCount, + Pointer atBufPtr, + IntPtr atBufCap, + Pointer sysexBufPtr, + IntPtr sysexBufCap, + Pointer packetsOutPtr, // flat [u8;4] × N + IntPtr packetsOutCap, + Pointer packetsOutCount, +); +typedef _HtpEncodeToPacketsDart = int Function( + Pointer namePtr, + int nameLen, + int id, + int op, + Pointer paramsPtr, + int paramsCount, + Pointer atBufPtr, + int atBufCap, + Pointer sysexBufPtr, + int sysexBufCap, + Pointer packetsOutPtr, + int packetsOutCap, + Pointer packetsOutCount, +); + +// --------------------------------------------------------------------------- +// AT operation enum (mirrors Rust AtOp) +// --------------------------------------------------------------------------- + +/// AT command operation type. +enum AtOp { + set(0), + query(1), + response(2); + + const AtOp(this.value); + final int value; +} + +// --------------------------------------------------------------------------- +// Parsed AT message (high-level result) +// --------------------------------------------------------------------------- + +/// Parsed AT command result returned by [HexaTuneProto.parseAt]. +class AtParseResult { + final String name; + final int id; + final AtOp op; + final List params; + + const AtParseResult({ + required this.name, + required this.id, + required this.op, + required this.params, + }); + + @override + String toString() => 'AtParseResult(name=$name, id=$id, op=$op, params=$params)'; +} + +// --------------------------------------------------------------------------- +// Proto error +// --------------------------------------------------------------------------- + +/// Error thrown when a native htp_* function returns a negative code. +class HexaTuneProtoError implements Exception { + final int code; + final String function; + + HexaTuneProtoError(this.code, this.function); + + static const _errorNames = { + 1: 'BufferTooSmall', + 2: 'InvalidSysex', + 3: 'InvalidAtCommand', + 4: 'InvalidId', + 5: 'PayloadNotUtf8', + 6: 'InvalidPacket', + 7: 'Overflow', + 8: 'EmptyInput', + 9: 'MissingPrefix', + 10: 'MissingOperator', + }; + + String get name => _errorNames[code] ?? 'Unknown($code)'; + + @override + String toString() => 'HexaTuneProtoError: $name (code=$code) in $function'; +} + +// --------------------------------------------------------------------------- +// Main API class +// --------------------------------------------------------------------------- + +/// High-level Dart interface to the hexaTuneProto native library. +/// +/// Wraps all `htp_*` C functions with safe Dart types. +class HexaTuneProto { + late final DynamicLibrary _lib; + + late final _HtpAtEncodeDart _atEncode; + late final _HtpAtParseDart _atParse; + late final _HtpSysexFrameDart _sysexFrame; + late final _HtpSysexUnframeDart _sysexUnframe; + late final _HtpUsbPacketizeDart _usbPacketize; + late final _HtpUsbDepacketizeDart _usbDepacketize; + late final _HtpEncodeToPacketsDart _encodeToPackets; + + /// Load the native library from the given [path]. + /// + /// On Android this is typically `'libhexa_tune_proto_ffi.so'`. + /// On iOS the library is statically linked — use [HexaTuneProto.open]. + HexaTuneProto(String path) : _lib = DynamicLibrary.open(path) { + _bindAll(); + } + + /// Use the process-level symbols (iOS static linking). + HexaTuneProto.open() : _lib = DynamicLibrary.process() { + _bindAll(); + } + + void _bindAll() { + _atEncode = _lib + .lookupFunction<_HtpAtEncodeNative, _HtpAtEncodeDart>('htp_at_encode'); + _atParse = _lib + .lookupFunction<_HtpAtParseNative, _HtpAtParseDart>('htp_at_parse'); + _sysexFrame = _lib + .lookupFunction<_HtpSysexFrameNative, _HtpSysexFrameDart>('htp_sysex_frame'); + _sysexUnframe = _lib + .lookupFunction<_HtpSysexUnframeNative, _HtpSysexUnframeDart>('htp_sysex_unframe'); + _usbPacketize = _lib + .lookupFunction<_HtpUsbPacketizeNative, _HtpUsbPacketizeDart>('htp_usb_packetize'); + _usbDepacketize = _lib + .lookupFunction<_HtpUsbDepacketizeNative, _HtpUsbDepacketizeDart>('htp_usb_depacketize'); + _encodeToPackets = _lib + .lookupFunction<_HtpEncodeToPacketsNative, _HtpEncodeToPacketsDart>('htp_encode_to_packets'); + } + + // ------------------------------------------------------------------------- + // AT encode + // ------------------------------------------------------------------------- + + /// Encode an AT command string. + /// + /// Returns the encoded bytes (e.g. `AT+FREQ=5#440#1000`). + Uint8List atEncode( + String name, { + int id = 0, + AtOp op = AtOp.set, + List params = const [], + }) { + final nameBytes = name.codeUnits; + final namePtr = calloc(nameBytes.length); + final outPtr = calloc(512); + final outLen = calloc(1); + + // Copy name + for (var i = 0; i < nameBytes.length; i++) { + namePtr[i] = nameBytes[i]; + } + + // Build params + Pointer paramsPtr = nullptr; + final paramPtrs = >[]; + + if (params.isNotEmpty) { + paramsPtr = calloc(params.length); + for (var i = 0; i < params.length; i++) { + final bytes = params[i].codeUnits; + final p = calloc(bytes.length); + for (var j = 0; j < bytes.length; j++) { + p[j] = bytes[j]; + } + paramPtrs.add(p); + paramsPtr[i].ptr = p; + paramsPtr[i].len = bytes.length; + } + } + + try { + final rc = _atEncode( + namePtr, nameBytes.length, + id, op.value, + paramsPtr, params.length, + outPtr, 512, outLen, + ); + if (rc < 0) throw HexaTuneProtoError(-rc, 'htp_at_encode'); + return Uint8List.fromList(outPtr.asTypedList(outLen.value)); + } finally { + calloc.free(namePtr); + calloc.free(outPtr); + calloc.free(outLen); + for (final p in paramPtrs) { + calloc.free(p); + } + if (paramsPtr != nullptr) calloc.free(paramsPtr); + } + } + + // ------------------------------------------------------------------------- + // AT parse + // ------------------------------------------------------------------------- + + /// Parse an AT command from raw bytes. + AtParseResult atParse(Uint8List input) { + final inputPtr = calloc(input.length); + final resultPtr = calloc(1); + + for (var i = 0; i < input.length; i++) { + inputPtr[i] = input[i]; + } + + try { + final rc = _atParse(inputPtr, input.length, resultPtr); + if (rc < 0) throw HexaTuneProtoError(-rc, 'htp_at_parse'); + + final r = resultPtr.ref; + final name = String.fromCharCodes( + input.sublist(r.nameOffset, r.nameOffset + r.nameLen), + ); + final params = []; + for (var i = 0; i < r.paramCount; i++) { + final off = r.paramOffsets[i]; + final len = r.paramLens[i]; + params.add(String.fromCharCodes(input.sublist(off, off + len))); + } + + return AtParseResult( + name: name, + id: r.id, + op: AtOp.values.firstWhere((e) => e.value == r.op), + params: params, + ); + } finally { + calloc.free(inputPtr); + calloc.free(resultPtr); + } + } + + // ------------------------------------------------------------------------- + // SysEx frame / unframe + // ------------------------------------------------------------------------- + + /// Frame payload bytes into a SysEx message (F0 … payload … F7). + Uint8List sysexFrame(Uint8List payload) { + final inPtr = calloc(payload.length); + final outPtr = calloc(payload.length + 2); + final outLen = calloc(1); + + for (var i = 0; i < payload.length; i++) { + inPtr[i] = payload[i]; + } + + try { + final rc = _sysexFrame( + inPtr, payload.length, + outPtr, payload.length + 2, outLen, + ); + if (rc < 0) throw HexaTuneProtoError(-rc, 'htp_sysex_frame'); + return Uint8List.fromList(outPtr.asTypedList(outLen.value)); + } finally { + calloc.free(inPtr); + calloc.free(outPtr); + calloc.free(outLen); + } + } + + /// Extract payload from a SysEx message. + Uint8List sysexUnframe(Uint8List data) { + final inPtr = calloc(data.length); + final outOffset = calloc(1); + final outLen = calloc(1); + + for (var i = 0; i < data.length; i++) { + inPtr[i] = data[i]; + } + + try { + final rc = _sysexUnframe(inPtr, data.length, outOffset, outLen); + if (rc < 0) throw HexaTuneProtoError(-rc, 'htp_sysex_unframe'); + final offset = outOffset.value; + final length = outLen.value; + return Uint8List.fromList(data.sublist(offset, offset + length)); + } finally { + calloc.free(inPtr); + calloc.free(outOffset); + calloc.free(outLen); + } + } + + // ------------------------------------------------------------------------- + // USB MIDI packetize / depacketize + // ------------------------------------------------------------------------- + + /// Convert SysEx bytes to USB MIDI 4-byte packets. + /// + /// Returns a flat [Uint8List] of length `packetCount * 4`. + Uint8List usbPacketize(Uint8List sysex) { + final maxPackets = (sysex.length ~/ 3) + 2; + final inPtr = calloc(sysex.length); + final outPtr = calloc(maxPackets * 4); + final outCount = calloc(1); + + for (var i = 0; i < sysex.length; i++) { + inPtr[i] = sysex[i]; + } + + try { + final rc = _usbPacketize( + inPtr, sysex.length, + outPtr, maxPackets, outCount, + ); + if (rc < 0) throw HexaTuneProtoError(-rc, 'htp_usb_packetize'); + final count = outCount.value; + return Uint8List.fromList(outPtr.asTypedList(count * 4)); + } finally { + calloc.free(inPtr); + calloc.free(outPtr); + calloc.free(outCount); + } + } + + /// Reassemble SysEx bytes from USB MIDI 4-byte packets. + /// + /// [packets] must be a flat byte list of length `packetCount * 4`. + Uint8List usbDepacketize(Uint8List packets) { + assert(packets.length % 4 == 0, 'packets length must be multiple of 4'); + final packetCount = packets.length ~/ 4; + final inPtr = calloc(packets.length); + final outPtr = calloc(packetCount * 3 + 2); + final outLen = calloc(1); + + for (var i = 0; i < packets.length; i++) { + inPtr[i] = packets[i]; + } + + try { + final rc = _usbDepacketize( + inPtr, packetCount, + outPtr, packetCount * 3 + 2, outLen, + ); + if (rc < 0) throw HexaTuneProtoError(-rc, 'htp_usb_depacketize'); + return Uint8List.fromList(outPtr.asTypedList(outLen.value)); + } finally { + calloc.free(inPtr); + calloc.free(outPtr); + calloc.free(outLen); + } + } + + // ------------------------------------------------------------------------- + // Full pipeline + // ------------------------------------------------------------------------- + + /// Encode AT command → SysEx → USB MIDI packets in one call. + /// + /// Returns flat packet bytes (length = packetCount * 4). + Uint8List encodeToPackets( + String name, { + int id = 0, + AtOp op = AtOp.set, + List params = const [], + }) { + final nameBytes = name.codeUnits; + final namePtr = calloc(nameBytes.length); + final atBuf = calloc(512); + final sysexBuf = calloc(514); + final packetsBuf = calloc(256 * 4); + final packetsCount = calloc(1); + + for (var i = 0; i < nameBytes.length; i++) { + namePtr[i] = nameBytes[i]; + } + + Pointer paramsPtr = nullptr; + final paramPtrs = >[]; + + if (params.isNotEmpty) { + paramsPtr = calloc(params.length); + for (var i = 0; i < params.length; i++) { + final bytes = params[i].codeUnits; + final p = calloc(bytes.length); + for (var j = 0; j < bytes.length; j++) { + p[j] = bytes[j]; + } + paramPtrs.add(p); + paramsPtr[i].ptr = p; + paramsPtr[i].len = bytes.length; + } + } + + try { + final rc = _encodeToPackets( + namePtr, nameBytes.length, + id, op.value, + paramsPtr, params.length, + atBuf, 512, + sysexBuf, 514, + packetsBuf, 256, + packetsCount, + ); + if (rc < 0) throw HexaTuneProtoError(-rc, 'htp_encode_to_packets'); + final count = packetsCount.value; + return Uint8List.fromList(packetsBuf.asTypedList(count * 4)); + } finally { + calloc.free(namePtr); + calloc.free(atBuf); + calloc.free(sysexBuf); + calloc.free(packetsBuf); + calloc.free(packetsCount); + for (final p in paramPtrs) { + calloc.free(p); + } + if (paramsPtr != nullptr) calloc.free(paramsPtr); + } + } +} diff --git a/hexagenapp/lib/src/core/proto/proto_service.dart b/hexagenapp/lib/src/core/proto/proto_service.dart new file mode 100644 index 0000000..66b158f --- /dev/null +++ b/hexagenapp/lib/src/core/proto/proto_service.dart @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +import 'dart:io' show Platform; +import 'hexa_tune_proto_ffi.dart'; + +/// Singleton service that loads and holds the native hexaTuneProto FFI instance. +class ProtoService { + static final ProtoService _instance = ProtoService._internal(); + factory ProtoService() => _instance; + + late final HexaTuneProto _proto; + + ProtoService._internal() { + if (Platform.isAndroid) { + _proto = HexaTuneProto('libhexa_tune_proto_ffi.so'); + } else if (Platform.isIOS) { + _proto = HexaTuneProto.open(); + } else { + throw UnsupportedError('Platform not supported for hexaTuneProto FFI'); + } + } + + /// The native protocol instance. + HexaTuneProto get proto => _proto; +} diff --git a/hexagenapp/lib/src/core/service/device_service.dart b/hexagenapp/lib/src/core/service/device_service.dart index 97fd619..77e8f6f 100644 --- a/hexagenapp/lib/src/core/service/device_service.dart +++ b/hexagenapp/lib/src/core/service/device_service.dart @@ -6,7 +6,8 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_midi_command/flutter_midi_command.dart'; import 'package:hexagenapp/src/core/device/device.dart'; -import 'package:hexagenapp/src/core/at/at.dart'; +import 'package:hexagenapp/src/core/proto/at_command.dart'; +import 'package:hexagenapp/src/core/proto/at_response.dart'; import 'package:hexagenapp/src/core/error/error.dart'; import 'package:hexagenapp/src/core/service/log_service.dart'; diff --git a/hexagenapp/lib/src/core/sysex/sysex.dart b/hexagenapp/lib/src/core/sysex/sysex.dart deleted file mode 100644 index 80eb23b..0000000 --- a/hexagenapp/lib/src/core/sysex/sysex.dart +++ /dev/null @@ -1,161 +0,0 @@ -// SPDX-FileCopyrightText: 2025 hexaTune LLC -// SPDX-License-Identifier: MIT - -import 'dart:typed_data'; -import 'dart:convert' show utf8; - -/// SysEx message utilities for MIDI communication -class SysEx { - /// Build SysEx message from string payload - static Uint8List buildSysEx(String payload) { - final payloadBytes = utf8.encode(payload); - final out = Uint8List(payloadBytes.length + 2); - - out[0] = 0xF0; // SysEx start - for (int i = 0; i < payloadBytes.length; i++) { - out[i + 1] = payloadBytes[i]; - } - out[out.length - 1] = 0xF7; // SysEx end - - return out; - } - - /// Convert SysEx to USB MIDI packets (4-byte format) - /// Format: [CIN, byte1, byte2, byte3] - /// CIN codes: - /// 0x04 = SysEx start or continue (3 data bytes) - /// 0x05 = SysEx end with 1 data byte - /// 0x06 = SysEx end with 2 data bytes - /// 0x07 = SysEx end with 3 data bytes - static Uint8List sysexToUsbMidiPackets(Uint8List sysex) { - final packets = []; - - int i = 0; - while (i < sysex.length) { - final rem = sysex.length - i; - - if (rem >= 3) { - // Check if this is the last 3 bytes and ends with 0xF7 - if (rem == 3 && sysex[sysex.length - 1] == 0xF7) { - // End with 3 bytes - packets.addAll([0x07, sysex[i], sysex[i + 1], sysex[i + 2]]); - i += 3; - } else { - // Start or continue with 3 bytes - packets.addAll([0x04, sysex[i], sysex[i + 1], sysex[i + 2]]); - i += 3; - } - } else if (rem == 2) { - // End with 2 bytes - packets.addAll([0x06, sysex[i], sysex[i + 1], 0x00]); - i += 2; - } else { - // End with 1 byte - packets.addAll([0x05, sysex[i], 0x00, 0x00]); - i += 1; - } - } - - return Uint8List.fromList(packets); - } - - /// Extract SysEx payload from USB MIDI packets OR raw MIDI bytes - /// Returns the payload without F0 and F7 markers - static String? extractSysexPayload(Uint8List data) { - // Check if this is USB MIDI packet format (4-byte chunks with CIN) - // or raw MIDI bytes (starts with 0xF0) - final isUsbMidiPackets = data.length >= 4 && (data[0] & 0xF0) == 0x00; - - if (isUsbMidiPackets) { - return _extractFromUsbMidiPackets(data); - } else { - return _extractFromRawBytes(data); - } - } - - /// Extract from USB MIDI packet format - static String? _extractFromUsbMidiPackets(Uint8List data) { - final out = []; - - // Process 4-byte chunks - for (int i = 0; i < data.length; i += 4) { - if (i + 3 >= data.length) break; - - final cin = data[i] & 0x0F; - final b1 = data[i + 1]; - final b2 = data[i + 2]; - final b3 = data[i + 3]; - - switch (cin) { - case 0x04: // SysEx continue/start (3 bytes) - if (b1 != 0) out.add(b1); - if (b2 != 0) out.add(b2); - if (b3 != 0) out.add(b3); - break; - - case 0x05: // End with 1 byte - if (b1 != 0) out.add(b1); - break; - - case 0x06: // End with 2 bytes - if (b1 != 0) out.add(b1); - if (b2 != 0) out.add(b2); - break; - - case 0x07: // End with 3 bytes - if (b1 != 0) out.add(b1); - if (b2 != 0) out.add(b2); - if (b3 != 0) out.add(b3); - break; - } - } - - // Check for F0 and F7 markers - if (out.isEmpty || out.first != 0xF0 || out.last != 0xF7) { - return null; - } - - // Extract payload (remove F0 and F7) - final payload = out.sublist(1, out.length - 1); - - try { - return utf8.decode(payload, allowMalformed: true); - } catch (e) { - return null; - } - } - - /// Extract from raw MIDI bytes - static String? _extractFromRawBytes(Uint8List data) { - // Check for F0 and F7 markers - if (data.isEmpty || data.first != 0xF0) { - return null; - } - - // Find F7 end marker - int endIndex = -1; - for (int i = 0; i < data.length; i++) { - if (data[i] == 0xF7) { - endIndex = i; - break; - } - } - - if (endIndex == -1) { - return null; // No end marker found - } - - // Extract payload (between F0 and F7) - if (endIndex <= 1) { - return null; // No payload - } - - final payload = data.sublist(1, endIndex); - - try { - return utf8.decode(payload, allowMalformed: true); - } catch (e) { - return null; - } - } -} diff --git a/hexagenapp/lib/src/pages/generation.dart b/hexagenapp/lib/src/pages/generation.dart index 736d84b..34d6c2c 100644 --- a/hexagenapp/lib/src/pages/generation.dart +++ b/hexagenapp/lib/src/pages/generation.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:hexagenapp/l10n/app_localizations.dart'; +import 'package:hexagenapp/src/core/service/device_service.dart'; enum ItemStatus { pending, processing, completed, error } @@ -129,9 +130,34 @@ class _GenerationPageState extends State { @override Widget build(BuildContext context) { final lang = AppLocalizations.of(context)!; + final deviceService = DeviceServiceProvider.of(context); return Column( children: [ + // Simulation mode banner + if (!deviceService.isConnected) + Container( + color: Theme.of(context).colorScheme.tertiaryContainer, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Symbols.model_training, + size: 20, + color: Theme.of(context).colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + lang.simulationModeActive, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), // --- Fixed top: Slider + Add frequency --- Card( margin: EdgeInsets.zero, diff --git a/hexagenapp/lib/src/pages/history.dart b/hexagenapp/lib/src/pages/history.dart index 5f61bfb..acad89a 100644 --- a/hexagenapp/lib/src/pages/history.dart +++ b/hexagenapp/lib/src/pages/history.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:hexagenapp/l10n/app_localizations.dart'; import 'package:hexagenapp/src/core/service/storage_service.dart'; import 'package:intl/intl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; class HistoryPage extends StatefulWidget { final Function(List>, int)? onRegenerate; @@ -131,6 +132,7 @@ class _OperationCardState extends State<_OperationCard> { final items = widget.operation['items'] as List; final repeatCount = widget.operation['repeatCount'] as int; final timestamp = widget.operation['timestamp'] as String; + final isSimulated = widget.operation['isSimulated'] as bool? ?? false; return Card( margin: const EdgeInsets.only(bottom: 12), @@ -161,6 +163,15 @@ class _OperationCardState extends State<_OperationCard> { style: Theme.of(context).textTheme.titleMedium, ), ), + if (isSimulated) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon( + Symbols.model_training, + size: 20, + color: Theme.of(context).colorScheme.tertiary, + ), + ), IconButton( icon: const Icon(Icons.refresh), onPressed: _handleRegenerate, diff --git a/hexagenapp/lib/src/pages/layout.dart b/hexagenapp/lib/src/pages/layout.dart index 9c426d2..d30538e 100644 --- a/hexagenapp/lib/src/pages/layout.dart +++ b/hexagenapp/lib/src/pages/layout.dart @@ -12,7 +12,7 @@ import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:hexagenapp/l10n/app_localizations.dart'; import 'package:hexagenapp/src/core/service/device_service.dart'; import 'package:hexagenapp/src/core/service/storage_service.dart'; -import 'package:hexagenapp/src/core/at/at.dart'; +import 'package:hexagenapp/src/core/proto/at_response.dart'; import 'package:hexagenapp/src/core/service/log_service.dart'; import 'package:hexagenapp/src/core/error/error.dart'; @@ -91,6 +91,44 @@ class _MainPageState extends State with WidgetsBindingObserver { } } + Future _executeSimulatedIteration( + dynamic state, + List> sequence, + ) async { + logger.print('MainPage: Starting simulated operation'); + + // Simulate each frequency step + for (int i = 0; i < sequence.length; i++) { + if (!_isSending) { + logger.print('MainPage: Simulation cancelled'); + return false; + } + + final item = sequence[i]; + final timeMs = ((item['seconds'] as double) * 1000).round(); + final freqHz = item['freqHz'] as int; + + state?.updateItemStatus(i, ItemStatus.processing); + logger.print( + 'MainPage: Simulating stepId=$i freq=$freqHz Hz time=${timeMs}ms', + ); + + // Wait for the frequency duration + await Future.delayed(Duration(milliseconds: timeMs)); + + if (!_isSending) { + logger.print('MainPage: Simulation cancelled during step $i'); + return false; + } + + state?.updateItemStatus(i, ItemStatus.completed); + logger.print('MainPage: Simulated stepId=$i completed'); + } + + logger.print('MainPage: Simulated iteration completed successfully'); + return true; + } + void _startOperation() async { final state = _generationKey.currentState as dynamic; final sequence = state?.getSequence() ?? []; @@ -128,84 +166,126 @@ class _MainPageState extends State with WidgetsBindingObserver { bool success = true; bool cancelled = false; - try { - // PHASE 1: PREPARE - logger.print( - 'MainPage: PHASE 1 - Sending PREPARE for operation $_operationId (iteration $_currentRepeatIteration)', - ); - final prepareStatus = await deviceService.sendOperationPrepare( - _operationId, - ); - - if (prepareStatus != CommandStatus.success) { - logger.print('MainPage: PREPARE failed with status: $prepareStatus'); - success = false; - } else { - logger.print('MainPage: PREPARE completed successfully'); - - // PHASE 2: FREQ batch - logger.print('MainPage: PHASE 2 - Sending FREQ commands'); - for (int i = 0; i < sequence.length && success; i++) { - if (!_isSending) { - cancelled = true; - break; - } + // Check if simulation mode is enabled + if (!deviceService.isConnected) { + logger.print('MainPage: Running in SIMULATION MODE'); + success = await _executeSimulatedIteration(state, sequence); + cancelled = !_isSending; + } else { + // Real device operation + try { + // PHASE 1: PREPARE + logger.print( + 'MainPage: PHASE 1 - Sending PREPARE for operation $_operationId (iteration $_currentRepeatIteration)', + ); + final prepareStatus = await deviceService.sendOperationPrepare( + _operationId, + ); - final item = sequence[i]; - final freqHz = item['freqHz'] as int; - final timeMs = ((item['seconds'] as double) * 1000).round(); - final stepId = i; // Use list index as stepId + if (prepareStatus != CommandStatus.success) { + logger.print('MainPage: PREPARE failed with status: $prepareStatus'); + success = false; + } else { + logger.print('MainPage: PREPARE completed successfully'); + + // PHASE 2: FREQ batch + logger.print('MainPage: PHASE 2 - Sending FREQ commands'); + for (int i = 0; i < sequence.length && success; i++) { + if (!_isSending) { + cancelled = true; + break; + } - state?.updateItemStatus(i, ItemStatus.processing); - logger.print( - 'MainPage: Sending FREQ stepId=$stepId freq=$freqHz Hz time=${timeMs}ms', - ); + final item = sequence[i]; + final freqHz = item['freqHz'] as int; + final timeMs = ((item['seconds'] as double) * 1000).round(); + final stepId = i; // Use list index as stepId - try { - final status = await deviceService.sendFreqCommandForOperation( - stepId, - freqHz, - timeMs, + state?.updateItemStatus(i, ItemStatus.processing); + logger.print( + 'MainPage: Sending FREQ stepId=$stepId freq=$freqHz Hz time=${timeMs}ms', ); - if (status != CommandStatus.success) { + try { + final status = await deviceService.sendFreqCommandForOperation( + stepId, + freqHz, + timeMs, + ); + + if (status != CommandStatus.success) { + logger.print( + 'MainPage: FREQ stepId=$stepId failed with status: $status', + ); + state?.updateItemStatus(i, ItemStatus.error); + success = false; + break; + } + + logger.print('MainPage: FREQ stepId=$stepId completed'); + state?.updateItemStatus(i, ItemStatus.completed); + } catch (e) { logger.print( - 'MainPage: FREQ stepId=$stepId failed with status: $status', + 'MainPage: Exception sending FREQ stepId=$stepId: $e', ); state?.updateItemStatus(i, ItemStatus.error); success = false; break; } - - logger.print('MainPage: FREQ stepId=$stepId completed'); - state?.updateItemStatus(i, ItemStatus.completed); - } catch (e) { - logger.print('MainPage: Exception sending FREQ stepId=$stepId: $e'); - state?.updateItemStatus(i, ItemStatus.error); - success = false; - break; } - } - // PHASE 3: GENERATE - if (success && !cancelled) { - logger.print( - 'MainPage: PHASE 3 - Sending GENERATE for operation $_operationId (iteration $_currentRepeatIteration)', - ); - state?.resetAllItemStatuses(); + // PHASE 3: GENERATE + if (success && !cancelled) { + logger.print( + 'MainPage: PHASE 3 - Sending GENERATE for operation $_operationId (iteration $_currentRepeatIteration)', + ); + state?.resetAllItemStatuses(); - await deviceService.sendOperationGenerate(_operationId); - logger.print('MainPage: GENERATE sent, starting polling'); - _startPolling(deviceService, state); + await deviceService.sendOperationGenerate(_operationId); + logger.print('MainPage: GENERATE sent, starting polling'); + _startPolling(deviceService, state); + } } + } catch (e) { + logger.print('MainPage: Exception in operation: $e'); + success = false; } - } catch (e) { - logger.print('MainPage: Exception in operation: $e'); - success = false; } - // Handle immediate failures (before polling starts) - if (!success || cancelled) { + // Handle immediate failures or simulation mode completion + if (!deviceService.isConnected) { + // Simulation mode: handle completion directly + if (success && !cancelled) { + _handleOperationCompletion(true, null, state); + } else { + _pollingTimer?.cancel(); + + if (cancelled) { + logger.print('MainPage: Simulation cancelled'); + state?.resetAllItemStatuses(); + deviceService.addNotification(lang.operationStoppedByUser); + } else { + state?.resetAllItemStatuses(); + final errorMessage = lang.operationFailedCheckDevice; + deviceService.addNotification(lang.operationFailedWithErrors); + + if (context.mounted) { + messenger.showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: errorColor, + ), + ); + } + } + + // Reset repeat tracking and stop + _currentRepeatIteration = 0; + _totalRepeats = 1; + setState(() => _isSending = false); + } + } else if (!success || cancelled) { + // Real device mode: handle failures _pollingTimer?.cancel(); deviceService.resetOperationState(); @@ -452,15 +532,17 @@ class _MainPageState extends State with WidgetsBindingObserver { final state = _generationKey.currentState as dynamic; final sequence = state?.getSequence() ?? []; final repeatCount = state?.getRepeatCount() ?? 1; + final storageService = StorageServiceProvider.of(context); + final deviceService = DeviceServiceProvider.of(context); final operation = { 'id': operationId, 'timestamp': DateTime.now().toIso8601String(), 'repeatCount': repeatCount, 'items': sequence, + 'isSimulated': !deviceService.isConnected, }; - final storageService = StorageServiceProvider.of(context); storageService.saveOperation(operation); final lang = AppLocalizations.of(context)!; @@ -588,7 +670,7 @@ class _MainPageState extends State with WidgetsBindingObserver { title: Text(lang.appName), actions: [ Icon( - Symbols.circle, + _isSending ? Icons.incomplete_circle : Symbols.circle, color: _isSending ? colorScheme.tertiary : colorScheme.error, ), IconButton( diff --git a/hexagenapp/pubspec.yaml b/hexagenapp/pubspec.yaml index b56f775..3d131c4 100644 --- a/hexagenapp/pubspec.yaml +++ b/hexagenapp/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: sdk: flutter intl: any storybook_flutter: any + ffi: ^2.1.0 flutter_midi_command: ^0.5.0 shared_preferences: ^2.3.3 flutter_launcher_icons: "^0.14.4"