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/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/layout.dart b/hexagenapp/lib/src/pages/layout.dart
index 64a4adb..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';
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"