commit e1beed623d46243a2730a6d15611d545642bf980 Author: seuuung Date: Mon Sep 7 10:54:01 2026 +0900 DCT diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2581c0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,85 @@ +# ============================================================================== +# DCT Repository .gitignore +# GitHub에는 Android/ 및 DCT_test/ 폴더만 업로드 +# ============================================================================== + +# ------------------------------------------------------------------------------ +# 1. 루트 레벨: Android와 DCT_test, .gitignore 외 모든 폴더/파일 제외 +# ------------------------------------------------------------------------------ +/* +!/.gitignore +!/Android/ +!/DCT_test/ + +# ------------------------------------------------------------------------------ +# 2. Android 빌드 산출물 및 로컬 환경 제외 +# ------------------------------------------------------------------------------ +Android/.gradle/ +Android/**/.gradle/ +Android/build/ +Android/**/build/ +Android/.kotlin/ +Android/**/.kotlin/ +Android/**/bin/ +Android/captures/ +Android/**/captures/ +Android/.externalNativeBuild/ +Android/**/.externalNativeBuild/ +Android/.cxx/ +Android/**/.cxx/ + +# Android 로컬 설정 및 보안 키 +Android/local.properties +Android/**/local.properties +Android/**/*.jks +Android/**/*.keystore +Android/**/*.key +Android/**/*.p12 + +# Android 빌드 결과물 및 임시 텍스트/로그 +Android/**/*.apk +Android/**/*.ap_ +Android/**/*.aab +Android/**/*.dex +Android/**/*.class +Android/*_out.txt +Android/**/*_out.txt + +# Android IDE 및 에디터 캐시 +Android/.idea/ +Android/**/.idea/ +Android/**/*.iml +Android/*.iws +Android/*.ipr +Android/.vscode/ +Android/**/.vscode/ + +# ------------------------------------------------------------------------------ +# 3. DCT_test 빌드 산출물 및 캐시 제외 +# ------------------------------------------------------------------------------ +DCT_test/.vs/ +DCT_test/**/.vs/ +DCT_test/**/[Bb]in/ +DCT_test/**/[Oo]bj/ +DCT_test/**/*.user +DCT_test/**/*.userosscache +DCT_test/**/*.suo +DCT_test/**/*.sln.docstates +DCT_test/**/*.vsidx +DCT_test/.idea/ +DCT_test/**/.idea/ +DCT_test/.vscode/ +DCT_test/**/.vscode/ + +# ------------------------------------------------------------------------------ +# 4. 공통 임시 파일 및 OS 캐시 (하위 디렉토리 전체 적용) +# ------------------------------------------------------------------------------ +**/*.bak +**/*.tmp +**/*.log +**/*.swp +**/*.swo +**/.DS_Store +**/Thumbs.db +**/desktop.ini +**/.agents/ diff --git a/Android/.gitignore b/Android/.gitignore new file mode 100644 index 0000000..dfe7c49 --- /dev/null +++ b/Android/.gitignore @@ -0,0 +1,18 @@ +*.iml +.gradle +/local.properties +/.idea +.kotlin +.vscode +.agents +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +*.apk +*.aab +*.dex +*.class +*_out.txt +*.log diff --git a/Android/DCT Work/Configuration/AUTO_TEST.dpc b/Android/DCT Work/Configuration/AUTO_TEST.dpc new file mode 100644 index 0000000..23cec06 --- /dev/null +++ b/Android/DCT Work/Configuration/AUTO_TEST.dpc @@ -0,0 +1,678 @@ +{ + "cartype": "GENESIS", + "view_rect": {"items": [{ + "top": 138, + "left": 176, + "bottom": 891, + "maximized": false, + "name": "mainWindow", + "right": 1616 + }]}, + "canmode": 1, + "logfile_path_use_basefolder": 1, + "isave_logfile": 1, + "copy_savedfolder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\DCT Work/Data", + "maker": 2, + "version": "0.0.0.2", + "writemf4": 0, + "android_parameter_descriptions": {}, + "sm_property": {"modules": [ + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-16 ch", + "index": 15, + "description": "" + } + ], + "index": 0, + "is_used": 1 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-16 ch", + "index": 15, + "description": "" + } + ], + "index": 1, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-16 ch", + "index": 15, + "description": "" + } + ], + "index": 2, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-16 ch", + "index": 15, + "description": "" + } + ], + "index": 3, + "is_used": 0 + } + ]}, + "external_file": [ + { + "ext": "mdb", + "path": "\\MDB\\Engine.mdb", + "mdbfile_path_use_basefolder": 1, + "filename": "Engine.mdb" + }, + { + "ext": "dbc", + "path": "", + "dbcfile_path_use_basefolder": 0 + }, + { + "ext": "smf", + "path": "", + "smffile_path_use_basefolder": 0 + }, + { + "ext": "a2l", + "path": "", + "a2lfile_path_use_basefolder": 0 + } + ], + "oldcanmode": 0, + "graphItem_property": {"graph_linear": true}, + "hw_device": { + "host_address": "", + "com_port": "", + "sm_count": 1, + "connectedType": 1, + "macaddr": "" + }, + "DID_INFO": { + "DIDF18BPD": "", + "DIDF1A1SC": "", + "DIDF100PN2": "", + "DIDF197SN": "", + "DIDF191HW": "", + "DIDF187PN": "", + "DIDF193SHW": "", + "DIDPreSetting": 49 + }, + "logfile_path": "\\Data", + "ask_info": { + "DependROM": false, + "seedKeyPath": "", + "ASKFWAddr": "", + "ASKSTMin": 0 + }, + "writecsv": 1, + "ota_info": {"ota_item": {"item": [ + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + } + ]}}, + "name": "AUTO_TEST.dpc", + "android_workspace": { + "schema_version": 1, + "pages": [{ + "scroll_y": 0, + "scroll_x": 0, + "mdb_search_query": "", + "signals": [], + "alphanumerical_signals": [], + "can_search_query": "", + "created_at": 1788419362700, + "id": "page_1", + "time_window_preset": "SEC_10", + "title": "Page 1", + "windows": [], + "time_window_sec": 10 + }], + "active_page_id": "page_1" + }, + "autostart_measuring_recording": 0, + "base_folder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\DCT Work" +} \ No newline at end of file diff --git a/Android/DCT Work/Configuration/Sample.mdb.dpc b/Android/DCT Work/Configuration/Sample.mdb.dpc new file mode 100644 index 0000000..d635f24 --- /dev/null +++ b/Android/DCT Work/Configuration/Sample.mdb.dpc @@ -0,0 +1,786 @@ +{ + "cartype": "", + "view_rect": {"items": [{ + "top": 138, + "left": 176, + "bottom": 891, + "maximized": false, + "name": "mainWindow", + "right": 1616 + }]}, + "copy_savedfolder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\DCT Work/Data", + "mdb_compensation_A_property": { + "items": [], + "NAME": "Compensation Type A" + }, + "writemf4": 0, + "android_parameter_descriptions": {}, + "external_file": [ + { + "ext": "mdb", + "path": "\\MDB\\Sample.mdb", + "mdbfile_path_use_basefolder": 1, + "filename": "Sample.mdb" + }, + { + "ext": "dbc", + "path": "", + "dbcfile_path_use_basefolder": 0 + }, + { + "ext": "smf", + "path": "", + "smffile_path_use_basefolder": 0 + }, + { + "ext": "a2l", + "path": "", + "a2lfile_path_use_basefolder": 0 + } + ], + "oldcanmode": 0, + "graphItem_property": {"graph_linear": true}, + "hw_device": { + "host_address": "", + "com_port": "", + "sm_count": 1, + "connectedType": 1, + "macaddr": "" + }, + "DID_INFO": { + "DIDF18BPD": "", + "DIDF1A1SC": "", + "DIDF100PN2": "", + "DIDF197SN": "", + "DIDF191HW": "", + "DIDF187PN": "", + "DIDF193SHW": "", + "DIDPreSetting": 49 + }, + "ota_info": {"ota_item": {"item": [ + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + } + ]}}, + "android_workspace": { + "schema_version": 1, + "pages": [{ + "scroll_y": 0, + "scroll_x": 0, + "created_at": 1788419362160, + "time_window_preset": "SEC_10", + "title": "Page 1", + "windows": [ + { + "z_index": 1, + "target_table_name": "", + "closable": true, + "maximized": false, + "dashboard_grid_mode": "GRID_3X3", + "created_at": 1788419362280, + "target_parameter_name": "", + "time_window_preset": "MIN_1", + "type": "SIGNAL_LIST", + "title": "Signal list", + "minimized": false, + "auto_scroll": true, + "tile_mode": true, + "content_state": {}, + "signals": [], + "target_signals": [], + "x": 16, + "width": 240, + "hidden_signal_ids": [], + "y": 16, + "id": "win_signal_list_35efb6", + "highlight_colors": {}, + "height": 520 + }, + { + "z_index": 2, + "target_table_name": "", + "closable": true, + "maximized": false, + "dashboard_grid_mode": "GRID_3X3", + "created_at": 1788419362280, + "target_parameter_name": "", + "time_window_preset": "MIN_1", + "type": "MDB_SIGNAL_LIST", + "title": "MDB Signal", + "minimized": false, + "auto_scroll": true, + "tile_mode": true, + "content_state": {}, + "signals": [], + "target_signals": [], + "x": 264, + "width": 420, + "hidden_signal_ids": [], + "y": 16, + "id": "win_mdb_signal_list_abc4cb", + "highlight_colors": {}, + "height": 520 + } + ], + "mdb_search_query": "", + "signals": [], + "alphanumerical_signals": [], + "can_search_query": "", + "id": "page_1", + "active_window_id": "win_mdb_signal_list_abc4cb", + "time_window_sec": 10 + }], + "active_page_id": "page_1" + }, + "autostart_measuring_recording": 0, + "base_folder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\DCT Work", + "canmode": 1, + "logfile_path_use_basefolder": 1, + "isave_logfile": 1, + "maker": 2, + "mdb_send_property": {"property": []}, + "version": "0.0.0.2", + "sm_property": {"modules": [ + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-16 ch", + "index": 15, + "description": "" + } + ], + "index": 0, + "is_used": 1 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-16 ch", + "index": 15, + "description": "" + } + ], + "index": 1, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-16 ch", + "index": 15, + "description": "" + } + ], + "index": 2, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-16 ch", + "index": 15, + "description": "" + } + ], + "index": 3, + "is_used": 0 + } + ]}, + "mdb_recv_property": {"property": [ + { + "name": "MDB Signal", + "items": [ + { + "MIN": 0, + "MAX": 8000, + "item_thickness": 2, + "Value": 0, + "item_dashstyle": 0, + "Index": 1, + "USER_COLOR": 4278211276, + "Descrip": "", + "Name": "Ta_Motor_Speed", + "ADDR": 0, + "ATTR": 2 + }, + { + "MIN": -40, + "MAX": 150, + "item_thickness": 2, + "Value": 0, + "item_dashstyle": 0, + "Index": 2, + "USER_COLOR": 4290386458, + "Descrip": "", + "Name": "Ta_Inverter_Temp", + "ADDR": 0, + "ATTR": 1 + } + ] + }, + { + "name": "Constant Signal", + "items": [{ + "MIN": 0, + "MAX": 500, + "Value": 350, + "Index": 1, + "USER_COLOR": -1, + "Descrip": "", + "Name": "K_MAX_TORQUE", + "ADDR": 4096, + "ATTR": 2 + }] + } + ]}, + "logfile_path": "\\Data", + "mdb_compensation_B_property": { + "items": [], + "NAME": "Compensation Type B" + }, + "ask_info": { + "DependROM": false, + "seedKeyPath": "", + "ASKFWAddr": "", + "ASKSTMin": 0 + }, + "writecsv": 1, + "name": "Sample.mdb.dpc" +} \ No newline at end of file diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_11-37-04_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_11-37-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_11-37-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_11-37-26_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_11-37-26_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_11-37-26_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_11-40-16_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_11-40-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_11-40-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_15-50-24_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_15-50-24_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_15-50-24_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_15-51-12_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_15-51-12_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_15-51-12_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_15-53-02_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_15-53-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_15-53-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_15-54-20_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_15-54-20_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_15-54-20_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_15-57-34_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_15-57-34_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_15-57-34_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_16-00-50_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_16-00-50_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_16-00-50_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_16-33-18_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_16-33-18_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_16-33-18_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_16-40-01_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_16-40-01_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_16-40-01_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_16-41-17_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_16-41-17_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_16-41-17_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_16-52-25_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_16-52-25_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_16-52-25_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_22-38-04_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_22-38-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_22-38-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_22-40-53_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_22-40-53_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_22-40-53_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-02_22-45-02_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-02_22-45-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-02_22-45-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_09-41-56_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_09-41-56_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_09-41-56_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_09-45-18_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_09-45-18_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_09-45-18_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_10-03-50_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_10-03-50_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_10-03-50_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_10-14-02_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_10-14-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_10-14-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_10-16-42_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_10-16-42_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_10-16-42_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_10-28-30_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_10-28-30_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_10-28-30_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_10-37-10_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_10-37-10_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_10-37-10_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_10-40-28_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_10-40-28_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_10-40-28_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_10-50-58_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_10-50-58_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_10-50-58_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_10-52-47_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_10-52-47_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_10-52-47_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_11-42-05_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_11-42-05_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_11-42-05_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_11-44-21_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_11-44-21_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_11-44-21_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_11-55-33_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_11-55-33_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_11-55-33_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_11-57-04_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_11-57-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_11-57-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_14-07-08_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_14-07-08_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_14-07-08_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_14-17-04_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_14-17-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_14-17-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_14-21-44_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_14-21-44_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_14-21-44_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_14-29-41_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_14-29-41_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_14-29-41_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_14-30-37_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_14-30-37_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_14-30-37_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_14-43-35_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_14-43-35_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_14-43-35_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_14-55-48-594_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_14-55-48-594_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_14-55-48-594_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_15-00-38-186_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_15-00-38-186_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_15-00-38-186_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_15-02-33-023_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_15-02-33-023_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_15-02-33-023_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_15-24-29-617_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_15-24-29-617_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_15-24-29-617_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_15-35-27-968_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_15-35-27-968_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_15-35-27-968_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_15-41-40-020_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_15-41-40-020_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_15-41-40-020_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_15-43-47-387_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_15-43-47-387_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_15-43-47-387_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_15-45-38-906_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_15-45-38-906_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_15-45-38-906_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_15-52-50-791_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_15-52-50-791_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_15-52-50-791_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_16-07-21-559_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_16-07-21-559_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_16-07-21-559_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/PROJECT_2026-09-03_16-09-22-691_sm.csv b/Android/DCT Work/Data/PROJECT_2026-09-03_16-09-22-691_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/PROJECT_2026-09-03_16-09-22-691_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_11-37-03_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_11-37-03_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_11-37-03_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_11-37-03_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_11-37-03_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_11-37-03_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_11-37-04_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_11-37-04_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_11-37-04_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_11-37-04_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_11-37-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_11-37-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_11-37-26_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_11-37-26_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_11-37-26_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_11-37-26_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_11-37-26_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_11-37-26_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_11-40-16_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_11-40-16_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_11-40-16_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_11-40-16_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_11-40-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_11-40-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-50-24_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-50-24_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-50-24_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-50-24_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-50-24_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-50-24_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-51-12_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-51-12_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-51-12_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-51-12_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-51-12_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-51-12_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-53-02_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-53-02_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-53-02_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-53-02_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-53-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-53-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-54-20_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-54-20_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-54-20_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-54-20_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-54-20_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-54-20_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-57-33_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-57-33_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-57-33_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-57-33_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-57-33_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-57-33_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-57-34_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-57-34_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-57-34_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_15-57-34_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_15-57-34_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_15-57-34_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-00-50_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-00-50_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-00-50_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-00-50_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-00-50_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-00-50_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-33-18_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-33-18_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-33-18_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-33-18_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-33-18_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-33-18_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-40-00_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-40-00_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-40-00_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-40-00_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-40-00_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-40-00_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-40-01_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-40-01_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-40-01_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-40-01_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-40-01_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-40-01_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-41-17_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-41-17_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-41-17_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-41-17_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-41-17_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-41-17_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-52-25_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-52-25_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-52-25_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_16-52-25_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_16-52-25_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_16-52-25_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_22-38-04_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_22-38-04_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_22-38-04_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_22-38-04_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_22-38-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_22-38-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_22-40-53_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_22-40-53_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_22-40-53_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_22-40-53_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_22-40-53_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_22-40-53_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_22-45-02_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-02_22-45-02_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_22-45-02_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-02_22-45-02_sm.csv b/Android/DCT Work/Data/Sample_2026-09-02_22-45-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-02_22-45-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_09-38-51_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_09-38-51_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_09-38-51_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_09-38-51_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_09-38-51_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_09-38-51_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_09-41-56_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_09-41-56_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_09-41-56_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_09-41-56_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_09-41-56_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_09-41-56_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_09-45-18_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_09-45-18_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_09-45-18_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_09-45-18_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_09-45-18_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_09-45-18_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-03-49_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-03-49_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-03-49_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-03-49_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-03-49_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-03-49_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-03-50_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-03-50_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-03-50_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-03-50_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-03-50_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-03-50_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-14-02_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-14-02_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-14-02_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-14-02_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-14-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-14-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-14-03_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-14-03_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-14-03_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-14-03_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-14-03_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-14-03_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-16-42_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-16-42_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-16-42_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-16-42_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-16-42_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-16-42_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-28-30_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-28-30_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-28-30_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-28-30_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-28-30_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-28-30_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-37-10_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-37-10_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-37-10_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-37-10_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-37-10_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-37-10_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-40-28_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-40-28_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-40-28_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-40-28_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-40-28_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-40-28_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-50-58_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-50-58_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-50-58_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-50-58_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-50-58_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-50-58_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-52-47_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-52-47_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-52-47_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_10-52-47_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_10-52-47_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_10-52-47_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_11-42-05_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_11-42-05_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_11-42-05_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_11-42-05_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_11-42-05_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_11-42-05_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_11-44-21_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_11-44-21_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_11-44-21_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_11-44-21_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_11-44-21_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_11-44-21_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_11-55-33_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_11-55-33_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_11-55-33_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_11-55-33_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_11-55-33_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_11-55-33_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_11-57-04_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_11-57-04_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_11-57-04_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_11-57-04_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_11-57-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_11-57-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-07-08_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-07-08_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-07-08_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-07-08_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-07-08_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-07-08_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-17-04_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-17-04_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-17-04_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-17-04_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-17-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-17-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-21-44_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-21-44_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-21-44_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-21-44_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-21-44_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-21-44_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-29-41_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-29-41_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-29-41_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-29-41_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-29-41_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-29-41_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-30-37_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-30-37_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-30-37_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-30-37_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-30-37_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-30-37_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-43-35_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-43-35_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-43-35_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-43-35_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-43-35_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-43-35_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-378_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-378_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-378_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-378_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-378_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-378_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-638_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-638_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-638_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-638_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-638_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_14-55-48-638_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-00-37-948_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-00-37-948_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-00-37-948_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-00-37-948_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-00-37-948_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-00-37-948_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-00-38-235_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-00-38-235_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-00-38-235_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-00-38-235_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-00-38-235_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-00-38-235_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-02-32-817_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-02-32-817_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-02-32-817_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-02-32-817_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-02-32-817_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-02-32-817_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-02-33-060_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-02-33-060_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-02-33-060_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-02-33-060_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-02-33-060_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-02-33-060_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-390_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-390_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-390_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-390_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-390_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-390_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-660_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-660_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-660_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-660_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-660_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-24-29-660_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-35-27-744_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-35-27-744_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-35-27-744_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-35-27-744_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-35-27-744_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-35-27-744_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-35-28-014_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-35-28-014_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-35-28-014_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-35-28-014_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-35-28-014_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-35-28-014_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-41-39-794_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-41-39-794_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-41-39-794_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-41-39-794_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-41-39-794_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-41-39-794_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-41-40-065_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-41-40-065_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-41-40-065_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-41-40-065_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-41-40-065_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-41-40-065_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-160_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-160_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-160_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-160_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-160_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-160_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-433_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-433_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-433_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-433_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-433_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-43-47-433_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-692_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-692_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-692_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-692_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-692_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-692_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-958_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-958_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-958_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-958_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-958_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-45-38-958_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-552_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-552_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-552_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-552_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-552_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-552_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-848_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-848_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-848_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-848_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-848_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_15-52-50-848_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-345_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-345_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-345_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-345_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-345_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-345_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-603_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-603_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-603_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-603_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-603_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_16-07-21-603_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-454_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-454_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-454_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-454_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-454_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-454_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-738_mdb.csv b/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-738_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-738_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-738_sm.csv b/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-738_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/DCT Work/Data/Sample_2026-09-03_16-09-22-738_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/.gitignore b/Android/app/.gitignore new file mode 100644 index 0000000..9850008 --- /dev/null +++ b/Android/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/Android/app/DCT Work/AUTO_TEST.dpc b/Android/app/DCT Work/AUTO_TEST.dpc new file mode 100644 index 0000000..8bd0e59 --- /dev/null +++ b/Android/app/DCT Work/AUTO_TEST.dpc @@ -0,0 +1,658 @@ +{ + "cartype": "GENESIS", + "view_rect": {"items": [{ + "top": 138, + "left": 176, + "bottom": 891, + "maximized": false, + "name": "mainWindow", + "right": 1616 + }]}, + "canmode": 1, + "logfile_path_use_basefolder": 1, + "isave_logfile": 1, + "copy_savedfolder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work/Data", + "maker": 2, + "version": "0.0.0.2", + "writemf4": 0, + "sm_property": {"modules": [ + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-16 ch", + "index": 15, + "description": "" + } + ], + "index": 0, + "is_used": 1 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-16 ch", + "index": 15, + "description": "" + } + ], + "index": 1, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-16 ch", + "index": 15, + "description": "" + } + ], + "index": 2, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-16 ch", + "index": 15, + "description": "" + } + ], + "index": 3, + "is_used": 0 + } + ]}, + "external_file": [ + { + "ext": "mdb", + "path": "\\MDB\\Engine.mdb", + "mdbfile_path_use_basefolder": 1, + "filename": "Engine.mdb" + }, + { + "ext": "dbc", + "path": "", + "dbcfile_path_use_basefolder": 0 + }, + { + "ext": "smf", + "path": "", + "smffile_path_use_basefolder": 0 + }, + { + "ext": "a2l", + "path": "", + "a2lfile_path_use_basefolder": 0 + } + ], + "oldcanmode": 0, + "graphItem_property": {"graph_linear": true}, + "hw_device": { + "host_address": "", + "com_port": "", + "sm_count": 1, + "connectedType": 1, + "macaddr": "" + }, + "DID_INFO": { + "DIDF18BPD": "", + "DIDF1A1SC": "", + "DIDF100PN2": "", + "DIDF197SN": "", + "DIDF191HW": "", + "DIDF187PN": "", + "DIDF193SHW": "", + "DIDPreSetting": 49 + }, + "logfile_path": "\\Data", + "ask_info": { + "seedKeyPath": "", + "ASKFWAddr": "", + "ASKSTMin": 0 + }, + "writecsv": 1, + "ota_info": {"ota_item": {"item": [ + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + } + ]}}, + "name": "AUTO_TEST.dpc", + "autostart_measuring_recording": 0, + "base_folder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work" +} \ No newline at end of file diff --git a/Android/app/DCT Work/Configuration/AUTO_TEST.dpc b/Android/app/DCT Work/Configuration/AUTO_TEST.dpc new file mode 100644 index 0000000..5c5af0e --- /dev/null +++ b/Android/app/DCT Work/Configuration/AUTO_TEST.dpc @@ -0,0 +1,678 @@ +{ + "cartype": "GENESIS", + "view_rect": {"items": [{ + "top": 138, + "left": 176, + "bottom": 891, + "maximized": false, + "name": "mainWindow", + "right": 1616 + }]}, + "canmode": 1, + "logfile_path_use_basefolder": 1, + "isave_logfile": 1, + "copy_savedfolder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work/Data", + "maker": 2, + "version": "0.0.0.2", + "writemf4": 0, + "android_parameter_descriptions": {}, + "sm_property": {"modules": [ + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-16 ch", + "index": 15, + "description": "" + } + ], + "index": 0, + "is_used": 1 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-16 ch", + "index": 15, + "description": "" + } + ], + "index": 1, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-16 ch", + "index": 15, + "description": "" + } + ], + "index": 2, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-16 ch", + "index": 15, + "description": "" + } + ], + "index": 3, + "is_used": 0 + } + ]}, + "external_file": [ + { + "ext": "mdb", + "path": "\\MDB\\Engine.mdb", + "mdbfile_path_use_basefolder": 1, + "filename": "Engine.mdb" + }, + { + "ext": "dbc", + "path": "", + "dbcfile_path_use_basefolder": 0 + }, + { + "ext": "smf", + "path": "", + "smffile_path_use_basefolder": 0 + }, + { + "ext": "a2l", + "path": "", + "a2lfile_path_use_basefolder": 0 + } + ], + "oldcanmode": 0, + "graphItem_property": {"graph_linear": true}, + "hw_device": { + "host_address": "", + "com_port": "", + "sm_count": 1, + "connectedType": 1, + "macaddr": "" + }, + "DID_INFO": { + "DIDF18BPD": "", + "DIDF1A1SC": "", + "DIDF100PN2": "", + "DIDF197SN": "", + "DIDF191HW": "", + "DIDF187PN": "", + "DIDF193SHW": "", + "DIDPreSetting": 49 + }, + "logfile_path": "\\Data", + "ask_info": { + "DependROM": false, + "seedKeyPath": "", + "ASKFWAddr": "", + "ASKSTMin": 0 + }, + "writecsv": 1, + "ota_info": {"ota_item": {"item": [ + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + } + ]}}, + "name": "AUTO_TEST.dpc", + "android_workspace": { + "schema_version": 1, + "pages": [{ + "scroll_y": 0, + "scroll_x": 0, + "mdb_search_query": "", + "signals": [], + "alphanumerical_signals": [], + "can_search_query": "", + "created_at": 1788421152937, + "id": "page_1", + "time_window_preset": "SEC_10", + "title": "Page 1", + "windows": [], + "time_window_sec": 10 + }], + "active_page_id": "page_1" + }, + "autostart_measuring_recording": 0, + "base_folder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work" +} \ No newline at end of file diff --git a/Android/app/DCT Work/Configuration/Sample.mdb.dpc b/Android/app/DCT Work/Configuration/Sample.mdb.dpc new file mode 100644 index 0000000..a4be14b --- /dev/null +++ b/Android/app/DCT Work/Configuration/Sample.mdb.dpc @@ -0,0 +1,786 @@ +{ + "cartype": "", + "view_rect": {"items": [{ + "top": 138, + "left": 176, + "bottom": 891, + "maximized": false, + "name": "mainWindow", + "right": 1616 + }]}, + "copy_savedfolder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work/Data", + "mdb_compensation_A_property": { + "items": [], + "NAME": "Compensation Type A" + }, + "writemf4": 0, + "android_parameter_descriptions": {}, + "external_file": [ + { + "ext": "mdb", + "path": "\\MDB\\Sample.mdb", + "mdbfile_path_use_basefolder": 1, + "filename": "Sample.mdb" + }, + { + "ext": "dbc", + "path": "", + "dbcfile_path_use_basefolder": 0 + }, + { + "ext": "smf", + "path": "", + "smffile_path_use_basefolder": 0 + }, + { + "ext": "a2l", + "path": "", + "a2lfile_path_use_basefolder": 0 + } + ], + "oldcanmode": 0, + "graphItem_property": {"graph_linear": true}, + "hw_device": { + "host_address": "", + "com_port": "", + "sm_count": 1, + "connectedType": 1, + "macaddr": "" + }, + "DID_INFO": { + "DIDF18BPD": "", + "DIDF1A1SC": "", + "DIDF100PN2": "", + "DIDF197SN": "", + "DIDF191HW": "", + "DIDF187PN": "", + "DIDF193SHW": "", + "DIDPreSetting": 49 + }, + "ota_info": {"ota_item": {"item": [ + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + } + ]}}, + "android_workspace": { + "schema_version": 1, + "pages": [{ + "scroll_y": 0, + "scroll_x": 0, + "created_at": 1788421152292, + "time_window_preset": "SEC_10", + "title": "Page 1", + "windows": [ + { + "z_index": 1, + "target_table_name": "", + "closable": true, + "maximized": false, + "dashboard_grid_mode": "GRID_3X3", + "created_at": 1788421152432, + "target_parameter_name": "", + "time_window_preset": "MIN_1", + "type": "SIGNAL_LIST", + "title": "Signal list", + "minimized": false, + "auto_scroll": true, + "tile_mode": true, + "content_state": {}, + "signals": [], + "target_signals": [], + "x": 16, + "width": 240, + "hidden_signal_ids": [], + "y": 16, + "id": "win_signal_list_5fa855", + "highlight_colors": {}, + "height": 520 + }, + { + "z_index": 2, + "target_table_name": "", + "closable": true, + "maximized": false, + "dashboard_grid_mode": "GRID_3X3", + "created_at": 1788421152432, + "target_parameter_name": "", + "time_window_preset": "MIN_1", + "type": "MDB_SIGNAL_LIST", + "title": "MDB Signal", + "minimized": false, + "auto_scroll": true, + "tile_mode": true, + "content_state": {}, + "signals": [], + "target_signals": [], + "x": 264, + "width": 420, + "hidden_signal_ids": [], + "y": 16, + "id": "win_mdb_signal_list_bd9b4a", + "highlight_colors": {}, + "height": 520 + } + ], + "mdb_search_query": "", + "signals": [], + "alphanumerical_signals": [], + "can_search_query": "", + "id": "page_1", + "active_window_id": "win_mdb_signal_list_bd9b4a", + "time_window_sec": 10 + }], + "active_page_id": "page_1" + }, + "autostart_measuring_recording": 0, + "base_folder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work", + "canmode": 1, + "logfile_path_use_basefolder": 1, + "isave_logfile": 1, + "maker": 2, + "mdb_send_property": {"property": []}, + "version": "0.0.0.2", + "sm_property": {"modules": [ + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "01-16 ch", + "index": 15, + "description": "" + } + ], + "index": 0, + "is_used": 1 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "02-16 ch", + "index": 15, + "description": "" + } + ], + "index": 1, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "03-16 ch", + "index": 15, + "description": "" + } + ], + "index": 2, + "is_used": 0 + }, + { + "channels": [ + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-01 ch", + "index": 0, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-02 ch", + "index": 1, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-03 ch", + "index": 2, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-04 ch", + "index": 3, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-05 ch", + "index": 4, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-06 ch", + "index": 5, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-07 ch", + "index": 6, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-08 ch", + "index": 7, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-09 ch", + "index": 8, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-10 ch", + "index": 9, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-11 ch", + "index": 10, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-12 ch", + "index": 11, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-13 ch", + "index": 12, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-14 ch", + "index": 13, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-15 ch", + "index": 14, + "description": "" + }, + { + "unit": "'C", + "color": 4278229580, + "is_voltage": 0, + "name": "04-16 ch", + "index": 15, + "description": "" + } + ], + "index": 3, + "is_used": 0 + } + ]}, + "mdb_recv_property": {"property": [ + { + "name": "MDB Signal", + "items": [ + { + "MIN": 0, + "MAX": 8000, + "item_thickness": 2, + "Value": 0, + "item_dashstyle": 0, + "Index": 1, + "USER_COLOR": 4278211276, + "Descrip": "", + "Name": "Ta_Motor_Speed", + "ADDR": 0, + "ATTR": 2 + }, + { + "MIN": -40, + "MAX": 150, + "item_thickness": 2, + "Value": 0, + "item_dashstyle": 0, + "Index": 2, + "USER_COLOR": 4290386458, + "Descrip": "", + "Name": "Ta_Inverter_Temp", + "ADDR": 0, + "ATTR": 1 + } + ] + }, + { + "name": "Constant Signal", + "items": [{ + "MIN": 0, + "MAX": 500, + "Value": 350, + "Index": 1, + "USER_COLOR": -1, + "Descrip": "", + "Name": "K_MAX_TORQUE", + "ADDR": 4096, + "ATTR": 2 + }] + } + ]}, + "logfile_path": "\\Data", + "mdb_compensation_B_property": { + "items": [], + "NAME": "Compensation Type B" + }, + "ask_info": { + "DependROM": false, + "seedKeyPath": "", + "ASKFWAddr": "", + "ASKSTMin": 0 + }, + "writecsv": 1, + "name": "Sample.mdb.dpc" +} \ No newline at end of file diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_112815.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_112815.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_112815.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_113103.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_113103.csv new file mode 100644 index 0000000..e60b28d --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_113103.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0001,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_125234.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_125234.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_125234.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_130759.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_130759.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_130759.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_131946.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_131946.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_131946.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_132323.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_132323.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_132323.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_132750.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_132750.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_132750.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_133030.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_133030.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_133030.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_133540.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_133540.csv new file mode 100644 index 0000000..c45aafc --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_133540.csv @@ -0,0 +1,2 @@ +Time(s),EngineSpeed [RPM],VehicleSpeed [km/h],CoolantTemp [degC],BatteryVoltage [V] +0.0015,0.0000,0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_133541.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_133541.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_133541.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_133902.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_133902.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_133902.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_134652.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_134652.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_134652.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_134908.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_134908.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_134908.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_135149.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_135149.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_135149.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_135542.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_135542.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_135542.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_135806.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_135806.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_135806.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_140830.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_140830.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_140830.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_141011.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_141011.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_141011.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_141809.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_141809.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_141809.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_142203.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_142203.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_142203.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_142429.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_142429.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_142429.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_142832.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_142832.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_142832.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_143145.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_143145.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_143145.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_144157.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_144157.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_144157.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_145628.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_145628.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_145628.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_145757.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_145757.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_145757.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_150926.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_150926.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_150926.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_151422.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_151422.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_151422.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_151832.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_151832.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_151832.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_160435.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_160435.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_160435.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_164514.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_164514.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_164514.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_164814.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_164814.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_164814.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_165257.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_165257.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_165257.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_165743.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_165743.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_165743.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_170527.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_170527.csv new file mode 100644 index 0000000..7719229 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_170527.csv @@ -0,0 +1,2 @@ +Time(s),EngineSpeed [RPM],VehicleSpeed [km/h],CoolantTemp [degC],BatteryVoltage [V] +0.0000,0.0000,0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_170528.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_170528.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_170528.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260825_170947.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260825_170947.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260825_170947.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_091323.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_091323.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_091323.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_091939.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_091939.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_091939.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_100240.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_100240.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_100240.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_100519.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_100519.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_100519.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_100941.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_100941.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_100941.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_101242.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_101242.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_101242.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_102051.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_102051.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_102051.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_102414.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_102414.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_102414.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_102713.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_102713.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_102713.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_103828.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_103828.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_103828.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_104635.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_104635.csv new file mode 100644 index 0000000..4dc8854 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_104635.csv @@ -0,0 +1,2 @@ +Time(s),EngineSpeed [RPM],VehicleSpeed [km/h],CoolantTemp [degC],BatteryVoltage [V] +0.0001,0.0000,0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_104636.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_104636.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_104636.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_105358.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_105358.csv new file mode 100644 index 0000000..4dc8854 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_105358.csv @@ -0,0 +1,2 @@ +Time(s),EngineSpeed [RPM],VehicleSpeed [km/h],CoolantTemp [degC],BatteryVoltage [V] +0.0001,0.0000,0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_105359.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_105359.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_105359.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_105503.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_105503.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_105503.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_110114.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_110114.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_110114.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_110718.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_110718.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_110718.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_111359.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_111359.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_111359.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_112319.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_112319.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_112319.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_113309.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_113309.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_113309.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_113350.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_113350.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_113350.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_121417.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_121417.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_121417.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_122126.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_122126.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_122126.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_123117.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_123117.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_123117.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_123820.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_123820.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_123820.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_124332.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_124332.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_124332.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_125651.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_125651.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_125651.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_125737.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_125737.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_125737.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_130725.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_130725.csv new file mode 100644 index 0000000..7719229 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_130725.csv @@ -0,0 +1,2 @@ +Time(s),EngineSpeed [RPM],VehicleSpeed [km/h],CoolantTemp [degC],BatteryVoltage [V] +0.0000,0.0000,0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_130726.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_130726.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_130726.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_131231.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_131231.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_131231.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_131531.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_131531.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_131531.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_131601.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_131601.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_131601.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_132601.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_132601.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_132601.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_133431.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_133431.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_133431.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_133606.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_133606.csv new file mode 100644 index 0000000..e60b28d --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_133606.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0001,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_133827.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_133827.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_133827.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_134336.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_134336.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_134336.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_143129.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_143129.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_143129.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_143149.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_143149.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_143149.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_143240.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_143240.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_143240.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_143911.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_143911.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_143911.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_144926.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_144926.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_144926.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_145752.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_145752.csv new file mode 100644 index 0000000..e60b28d --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_145752.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0001,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_150646.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_150646.csv new file mode 100644 index 0000000..e60b28d --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_150646.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0001,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_151831.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_151831.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_151831.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_152135.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_152135.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_152135.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_152412.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_152412.csv new file mode 100644 index 0000000..6049bea --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_152412.csv @@ -0,0 +1,2 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] +0.0000,0.0000,0.0000 diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_153715.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_153715.csv new file mode 100644 index 0000000..3d68425 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_153715.csv @@ -0,0 +1 @@ +Time(s) diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_153831.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_153831.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_153831.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_164312.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_164312.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_164312.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_170029.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_170029.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_170029.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_170524.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_170524.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_170524.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_170931.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_170931.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_170931.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_171007.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_171007.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_171007.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260826_191648.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260826_191648.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260826_191648.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260827_011900.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260827_011900.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260827_011900.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260827_011905.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260827_011905.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260827_011905.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260827_011910.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260827_011910.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260827_011910.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260827_012019.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260827_012019.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260827_012019.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260827_012353.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260827_012353.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260827_012353.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260827_012604.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260827_012604.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260827_012604.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260827_101607.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260827_101607.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260827_101607.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260827_105641.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260827_105641.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260827_105641.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/DCT_MEAS_20260827_111642.csv b/Android/app/DCT Work/Data/DCT_MEAS_20260827_111642.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/DCT_MEAS_20260827_111642.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-02-11_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-02-11_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-02-11_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-02-59_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-02-59_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-02-59_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-19-21_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-19-21_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-19-21_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-43-44_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-43-44_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-43-44_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-44-33_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-44-33_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-44-33_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-56-14_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-56-14_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_14-56-14_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-24-37_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-24-37_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-24-37_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-28-58_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-28-58_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-28-58_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-32-04_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-32-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-32-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-36-31_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-36-31_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-36-31_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-40-55_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-40-55_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-40-55_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-46-24_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-46-24_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-46-24_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-54-39_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-54-39_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_15-54-39_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-02-37_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-02-37_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-02-37_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-07-33_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-07-33_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-07-33_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-12-23_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-12-23_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-12-23_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-18-35_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-18-35_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-18-35_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-22-25_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-22-25_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-22-25_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-25-45_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-25-45_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-25-45_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-32-10_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-32-10_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-32-10_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-32-11_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-32-11_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-32-11_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-43-08_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-43-08_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-43-08_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-58-04_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-58-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-58-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-59-03_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-59-03_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_16-59-03_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-27_17-02-24_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-27_17-02-24_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-27_17-02-24_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-01-33_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-01-33_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-01-33_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-12-03_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-12-03_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-12-03_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-14-01_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-14-01_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-14-01_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-17-26_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-17-26_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-17-26_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-20-54_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-20-54_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-20-54_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-27-04_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-27-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-27-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-33-36_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-33-36_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-28_09-33-36_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_09-29-48_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_09-29-48_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_09-29-48_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_09-30-44_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_09-30-44_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_09-30-44_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_09-30-59_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_09-30-59_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_09-30-59_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-02-16_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-02-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-02-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-09-55_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-09-55_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-09-55_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-15-12_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-15-12_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-15-12_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-21-05_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-21-05_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-21-05_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-28-00_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-28-00_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-28-00_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-33-36_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-33-36_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-33-36_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-40-16_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-40-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-40-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-44-57_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-44-57_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-44-57_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-44-59_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-44-59_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-44-59_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-54-11_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-54-11_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_10-54-11_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-04-56_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-04-56_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-04-56_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-11-16_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-11-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-11-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-11-52_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-11-52_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-11-52_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-24-38_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-24-38_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-24-38_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-33-45_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-33-45_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-33-45_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-43-57_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-43-57_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-43-57_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-56-02_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-56-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_11-56-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_12-03-17_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_12-03-17_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_12-03-17_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_12-06-45_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_12-06-45_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_12-06-45_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_12-13-37_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_12-13-37_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_12-13-37_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_13-47-22_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_13-47-22_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_13-47-22_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_13-50-01_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_13-50-01_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_13-50-01_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_13-56-27_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_13-56-27_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_13-56-27_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_14-12-07_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_14-12-07_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_14-12-07_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_14-18-28_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_14-18-28_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_14-18-28_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-08-31_14-20-36_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-08-31_14-20-36_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-08-31_14-20-36_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-03-16_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-03-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-03-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-10-54_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-10-54_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-10-54_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-11-35_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-11-35_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-11-35_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-12-02_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-12-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-12-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-14-38_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-14-38_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-02_15-14-38_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-15-44-313_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-15-44-313_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-15-44-313_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-17-24-075_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-17-24-075_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-17-24-075_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-29-07-862_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-29-07-862_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-29-07-862_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-34-11-885_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-34-11-885_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-34-11-885_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-34-23-695_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-34-23-695_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-34-23-695_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-35-30-974_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-35-30-974_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-35-30-974_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-39-12-921_sm.csv b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-39-12-921_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/PROJECT_2026-09-03_16-39-12-921_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_13-13-06_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_13-13-06_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_13-13-06_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_13-16-01_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_13-16-01_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_13-16-01_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_13-19-33_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_13-19-33_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_13-19-33_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_13-34-57_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_13-34-57_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_13-34-57_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_13-36-04_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_13-36-04_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_13-36-04_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-11_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-11_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-11_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-11_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-11_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-11_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-59_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-59_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-59_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-59_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-59_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-02-59_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-19-21_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-19-21_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-19-21_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-19-21_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-19-21_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-19-21_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-43-44_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-43-44_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-43-44_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-43-44_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-43-44_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-43-44_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-44-33_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-44-33_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-44-33_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-44-33_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-44-33_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-44-33_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-56-14_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-56-14_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-56-14_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_14-56-14_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_14-56-14_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_14-56-14_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-24-37_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-24-37_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-24-37_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-24-37_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-24-37_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-24-37_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-28-58_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-28-58_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-28-58_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-28-58_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-28-58_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-28-58_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-32-04_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-32-04_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-32-04_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-32-04_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-32-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-32-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-31_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-31_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-31_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-31_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-31_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-31_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-32_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-32_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-32_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-32_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-32_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-36-32_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-40-55_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-40-55_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-40-55_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-40-55_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-40-55_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-40-55_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-46-24_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-46-24_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-46-24_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-46-24_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-46-24_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-46-24_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-54-39_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-54-39_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-54-39_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_15-54-39_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_15-54-39_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_15-54-39_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-02-37_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-02-37_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-02-37_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-02-37_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-02-37_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-02-37_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-07-33_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-07-33_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-07-33_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-07-33_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-07-33_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-07-33_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-12-23_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-12-23_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-12-23_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-12-23_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-12-23_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-12-23_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-18-35_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-18-35_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-18-35_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-18-35_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-18-35_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-18-35_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-22-25_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-22-25_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-22-25_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-22-25_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-22-25_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-22-25_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-25-45_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-25-45_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-25-45_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-25-45_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-25-45_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-25-45_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-10_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-10_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-10_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-10_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-10_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-10_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-11_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-11_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-11_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-11_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-11_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-32-11_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-43-08_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-43-08_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-43-08_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-43-08_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-43-08_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-43-08_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-58-04_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-58-04_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-58-04_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-58-04_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-58-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-58-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-59-03_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-59-03_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-59-03_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_16-59-03_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_16-59-03_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_16-59-03_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_17-02-24_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_17-02-24_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_17-02-24_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-27_17-02-24_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-27_17-02-24_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-27_17-02-24_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-01-33_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-01-33_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-01-33_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-01-33_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-01-33_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-01-33_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-12-03_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-12-03_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-12-03_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-12-03_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-12-03_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-12-03_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-14-01_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-14-01_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-14-01_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-14-01_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-14-01_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-14-01_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-17-26_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-17-26_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-17-26_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-17-26_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-17-26_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-17-26_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-20-54_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-20-54_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-20-54_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-20-54_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-20-54_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-20-54_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-27-04_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-27-04_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-27-04_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-27-04_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-27-04_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-27-04_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-33-36_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-33-36_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-33-36_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-28_09-33-36_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-28_09-33-36_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-28_09-33-36_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_09-29-48_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_09-29-48_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_09-29-48_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_09-29-48_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_09-29-48_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_09-29-48_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-44_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-44_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-44_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-44_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-44_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-44_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-59_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-59_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-59_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-59_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-59_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_09-30-59_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-02-16_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-02-16_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-02-16_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-02-16_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-02-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-02-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-09-55_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-09-55_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-09-55_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-09-55_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-09-55_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-09-55_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-15-12_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-15-12_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-15-12_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-15-12_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-15-12_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-15-12_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-21-05_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-21-05_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-21-05_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-21-05_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-21-05_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-21-05_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-28-00_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-28-00_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-28-00_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-28-00_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-28-00_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-28-00_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-33-36_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-33-36_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-33-36_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-33-36_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-33-36_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-33-36_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-40-16_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-40-16_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-40-16_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-40-16_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-40-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-40-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-57_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-57_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-57_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-57_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-57_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-57_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-59_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-59_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-59_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-59_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-59_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-44-59_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-54-11_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-54-11_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-54-11_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_10-54-11_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_10-54-11_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_10-54-11_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-56_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-56_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-56_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-56_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-56_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-56_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-57_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-57_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-57_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-57_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-57_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-04-57_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-16_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-16_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-16_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-16_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-52_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-52_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-52_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-52_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-52_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-11-52_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-24-38_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-24-38_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-24-38_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-24-38_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-24-38_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-24-38_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-45_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-45_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-45_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-45_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-45_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-45_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-46_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-46_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-46_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-46_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-46_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-33-46_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-43-57_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-43-57_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-43-57_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-43-57_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-43-57_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-43-57_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-56-02_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-56-02_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-56-02_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_11-56-02_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_11-56-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_11-56-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-17_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-17_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-17_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-17_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-17_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-17_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-18_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-18_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-18_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-18_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-18_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_12-03-18_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_12-06-45_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_12-06-45_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_12-06-45_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_12-06-45_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_12-06-45_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_12-06-45_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_12-13-37_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_12-13-37_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_12-13-37_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_12-13-37_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_12-13-37_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_12-13-37_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_13-47-22_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_13-47-22_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_13-47-22_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_13-47-22_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_13-47-22_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_13-47-22_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_13-50-01_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_13-50-01_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_13-50-01_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_13-50-01_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_13-50-01_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_13-50-01_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_13-56-27_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_13-56-27_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_13-56-27_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_13-56-27_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_13-56-27_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_13-56-27_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_14-12-07_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_14-12-07_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_14-12-07_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_14-12-07_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_14-12-07_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_14-12-07_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_14-18-28_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_14-18-28_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_14-18-28_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_14-18-28_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_14-18-28_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_14-18-28_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_14-20-36_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_14-20-36_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_14-20-36_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-08-31_14-20-36_sm.csv b/Android/app/DCT Work/Data/Sample_2026-08-31_14-20-36_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-08-31_14-20-36_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-16_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-16_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-16_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-16_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-16_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-16_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-17_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-17_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-17_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-17_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-17_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-03-17_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-10-54_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-10-54_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-10-54_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-10-54_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-10-54_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-10-54_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-35_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-35_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-35_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-35_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-35_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-35_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-36_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-36_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-36_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-36_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-36_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-11-36_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-12-02_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-12-02_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-12-02_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-12-02_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-12-02_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-12-02_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-14-38_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-14-38_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-14-38_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-02_15-14-38_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-02_15-14-38_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-02_15-14-38_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-43-909_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-43-909_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-43-909_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-43-909_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-43-909_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-43-909_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-44-403_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-44-403_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-44-403_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-44-403_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-44-403_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-15-44-403_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-23-702_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-23-702_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-23-702_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-23-702_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-23-702_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-23-702_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-24-158_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-24-158_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-24-158_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-24-158_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-24-158_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-17-24-158_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-564_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-564_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-564_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-564_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-564_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-564_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-930_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-930_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-930_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-930_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-930_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-930_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-478_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-478_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-478_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-478_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-478_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-478_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-973_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-973_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-973_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-973_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-973_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-973_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-368_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-368_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-368_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-368_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-368_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-368_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-770_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-770_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-770_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-770_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-770_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-770_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-30-674_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-30-674_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-30-674_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-30-674_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-30-674_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-30-674_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-31-047_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-31-047_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-31-047_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-31-047_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-31-047_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-35-31-047_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-649_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-649_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-649_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-649_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-649_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-649_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-989_mdb.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-989_mdb.csv new file mode 100644 index 0000000..5973529 --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-989_mdb.csv @@ -0,0 +1 @@ +Time(s),Ta_Motor_Speed [RPM],Ta_Inverter_Temp [°C] diff --git a/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-989_sm.csv b/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-989_sm.csv new file mode 100644 index 0000000..ad0c06b --- /dev/null +++ b/Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-989_sm.csv @@ -0,0 +1 @@ +Time(s),01-01 ch ['C],01-02 ch ['C],01-03 ch ['C],01-04 ch ['C],01-05 ch ['C],01-06 ch ['C],01-07 ch ['C],01-08 ch ['C],01-09 ch ['C],01-10 ch ['C],01-11 ch ['C],01-12 ch ['C],01-13 ch ['C],01-14 ch ['C],01-15 ch ['C],01-16 ch ['C] diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts new file mode 100644 index 0000000..773f3b1 --- /dev/null +++ b/Android/app/build.gradle.kts @@ -0,0 +1,144 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.dct.app" + compileSdk = 34 + + defaultConfig { + applicationId = "com.dct.app" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + isMinifyEnabled = false + isDebuggable = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = "21" + freeCompilerArgs += listOf( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api" + ) + } + + buildFeatures { + compose = true + viewBinding = true + buildConfig = true + } + + packaging { + resources { + excludes += listOf( + "/META-INF/{AL2.0,LGPL2.1}", + "META-INF/INDEX.LIST", + "META-INF/io.netty.versions.properties", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/license.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/notice.txt" + ) + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.all { + it.useJUnitPlatform() + it.maxHeapSize = "2048m" + it.testLogging { + events("passed", "skipped", "failed") + showStandardStreams = false + } + } + } +} + +dependencies { + // 1. Pure Java MDB Engine (Jackcess 4.0.11) & Apache Commons + implementation("com.healthmarketscience.jackcess:jackcess:4.0.11") + implementation("org.apache.commons:commons-lang3:3.14.0") + implementation("commons-logging:commons-logging:1.3.0") + + // 2. Android Core & Lifecycle + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.activity:activity-ktx:1.8.2") + implementation("androidx.fragment:fragment-ktx:1.6.2") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") + + // 3. Kotlin Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0") + + // 4. Jetpack Compose BOM & Material 3 (Stitch Design System) + implementation(platform("androidx.compose:compose-bom:2024.02.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.navigation:navigation-compose:2.7.7") + + // 5. Unit Testing Framework (JUnit 5 Jupiter, Vintage, MockK, Coroutines Test, Truth, Turbine) + testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.2") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("junit:junit:4.13.2") + testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.10.2") + testImplementation("io.mockk:mockk:1.13.9") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") + testImplementation("com.google.truth:truth:1.4.1") + testImplementation("app.cash.turbine:turbine:1.1.0") + testImplementation("org.json:json:20240303") + + // 6. Android Instrumented Tests + androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00")) + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} + +tasks.withType().configureEach { + binaryResultsDirectory.set(file("${layout.buildDirectory.get().asFile.absolutePath}/test-bin/${name}_${System.currentTimeMillis()}")) +} diff --git a/Android/app/proguard-rules.pro b/Android/app/proguard-rules.pro new file mode 100644 index 0000000..7da2d27 --- /dev/null +++ b/Android/app/proguard-rules.pro @@ -0,0 +1,16 @@ +# Jackcess Pure Java MDB Engine +-keep class com.healthmarketscience.jackcess.** { *; } +-dontwarn com.healthmarketscience.jackcess.** + +# javax.script Stub classes for Jackcess +-keep class javax.script.** { *; } +-dontwarn javax.script.** + +# Apache Commons Logging & Lang3 +-keep class org.apache.commons.logging.** { *; } +-keep class org.apache.commons.lang3.** { *; } +-dontwarn org.apache.commons.logging.** +-dontwarn org.apache.commons.lang3.** + +# Domain Data Models +-keep class com.mobi.dct.domain.model.** { *; } diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt new file mode 100644 index 0000000..d22ccf3 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt @@ -0,0 +1,92 @@ +package com.dct.app.presentation.dialog + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performTouchInput +import com.dct.app.presentation.project.NewProjectDialog +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Mouse and pen hover feed the same Compose HoverInteraction state layer. + * Hovering a non-interactive title must not paint the full-screen dismiss target. + * Run on a device/emulator; real stylus input still needs tablet verification. + */ +@OptIn(ExperimentalTestApi::class) +class ProjectDialogHoverTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun newProject_hoverDoesNotDimAndOutsideTapStillDismisses() { + val visible = mutableStateOf(true) + compose.setContent { + MaterialTheme { + if (visible.value) { + NewProjectDialog(onDismiss = { visible.value = false }, onConfirm = {}) + } + } + } + + assertStableHoverAndBodyTap("New project") + compose.onNode(isDialog()).performTouchInput { click(Offset(2f, 2f)) } + compose.onNode(isDialog()).assertDoesNotExist() + } + + @Test + fun openProject_hoverDoesNotDimAndCloseStillWorks() { + val visible = mutableStateOf(true) + compose.setContent { + MaterialTheme { + if (visible.value) { + OpenProjectDialog( + projectFiles = emptyList(), + onSelectFile = {}, + onBrowseOther = {}, + onDismiss = { visible.value = false } + ) + } + } + } + + assertStableHoverAndBodyTap("Open Project file") + compose.onNodeWithContentDescription("Close").performClick() + compose.onNode(isDialog()).assertDoesNotExist() + } + + private fun assertStableHoverAndBodyTap(title: String) { + val dialog = compose.onNode(isDialog()) + val before = dialog.captureToImage().toPixelMap() + val titleNode = compose.onNodeWithText(title, useUnmergedTree = true) + + titleNode.performMouseInput { enter(center) } + // Allow any default hover indication animation to finish before comparing. + compose.mainClock.advanceTimeBy(500) + compose.waitForIdle() + val after = dialog.captureToImage().toPixelMap() + + assertEquals(before.width, after.width) + assertEquals(before.height, after.height) + for (y in 0 until before.height) { + for (x in 0 until before.width) { + assertEquals("Hover changed dialog pixel ($x, $y)", before[x, y], after[x, y]) + } + } + + titleNode.performMouseInput { exit() } + titleNode.performTouchInput { click(center) } + dialog.assertExists() + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ChartNavigationUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ChartNavigationUiTest.kt new file mode 100644 index 0000000..1de655e --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ChartNavigationUiTest.kt @@ -0,0 +1,67 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import com.dct.app.domain.model.chart.ChartRenderSnapshot +import com.dct.app.presentation.chart.MultiXGraphCanvas +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class ChartNavigationUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun doubleTapInvokesTheLatestResetCallbackForBothGraphTypesWithoutPanning() { + val multiAxis = mutableStateOf(false) + val version = mutableStateOf(1) + var lastResetVersion = 0 + var pans = 0 + compose.setContent { + val capturedVersion = version.value + Box(Modifier.size(500.dp, 300.dp).testTag("graph")) { + MultiXGraphCanvas(ChartRenderSnapshot(0.0, 60.0, emptyList(), emptyMap(), legendPosition = 3), + isMultiAxis = multiAxis.value, onResetPan = { lastResetVersion = capturedVersion }, + onPanTime = { pans++ }) + } + } + for (multi in listOf(false, true)) { + compose.runOnIdle { multiAxis.value = multi; lastResetVersion = 0 } + // Recompose without restarting the pointerInput key: the callback must not go stale. + compose.runOnIdle { version.value++ } + compose.onNodeWithTag("graph").performTouchInput { doubleClick(center) } + compose.runOnIdle { assertEquals(version.value, lastResetVersion); assertEquals(0, pans) } + } + } + + @Test fun tinyFingerMovementKeepsTapWhileRealDragsPanInBothDirections() { + val pans = mutableListOf() + compose.setContent { + Box(Modifier.size(500.dp, 300.dp).testTag("graph")) { + MultiXGraphCanvas(ChartRenderSnapshot(0.0, 60.0, emptyList(), emptyMap(), legendPosition = 3), + onPanTime = { pans.add(it) }) + } + } + compose.onNodeWithTag("graph").performTouchInput { + down(center) + moveTo(center + Offset(1f, 0f)) + up() + } + compose.runOnIdle { assertTrue(pans.isEmpty()) } + compose.onNodeWithTag("graph").performTouchInput { + advanceEventTime(500) + swipe(center, center + Offset(100f, 0f), 300) + } + compose.runOnIdle { assertTrue(pans.sum() < 0.0); pans.clear() } + compose.onNodeWithTag("graph").performTouchInput { + swipe(center, center - Offset(100f, 0f), 300) + } + compose.runOnIdle { assertTrue(pans.sum() > 0.0) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/CompensationLayoutUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/CompensationLayoutUiTest.kt new file mode 100644 index 0000000..e9608fa --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/CompensationLayoutUiTest.kt @@ -0,0 +1,167 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.dct.app.domain.model.mdb.* +import com.dct.app.presentation.signaltree.* +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class CompensationLayoutUiTest { + @get:Rule val compose = createComposeRule() + private fun map(typeA: Boolean) = CompensationMap( + if (typeA) CompensationMapType.TYPE_A else CompensationMapType.TYPE_B, + 1, "Y", 0, MdbAttrType.ATTR_3_INT16, 0.0, 100.0, + "X", 0, MdbAttrType.ATTR_3_INT16, 1.0, 0.0, + points = listOf(MapPoint(0, 0, -100, -50.0), MapPoint(1, 100, 200, 150.0)) + ) + + @Test fun mapHeadersFitAtLargeFontScaleAndNarrowWidthForBothTypes() { + val typeA = mutableStateOf(true) + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) { + MaterialTheme { + CompensationTable(compensationMapColumns(typeA.value), listOf(map(typeA.value)), + "test:map", Modifier.size(280.dp, 140.dp), onRowClick = { _, _ -> }) + } + } + } + for (a in listOf(true, false)) { + compose.runOnIdle { typeA.value = a } + for (column in compensationMapColumns(a)) { + compose.onNodeWithText(column.title).performScrollTo().assertIsDisplayed() + val layouts = mutableListOf() + compose.onNodeWithText(column.title).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + assertTrue(column.title, layouts.isNotEmpty()) + assertFalse(column.title, layouts.single().hasVisualOverflow) + } + } + } + + @Test fun pointHeadersScrollInsteadOfShrinkingAndRetainRowClick() { + val typeA = mutableStateOf(true) + var clicked = -1 + compose.setContent { + MaterialTheme { + CompensationTable(compensationPointColumns(typeA.value), map(typeA.value).points, + "test:point", Modifier.size(120.dp, 140.dp), onRowClick = { index, _ -> clicked = index }) + } + } + for (a in listOf(true, false)) { + compose.runOnIdle { typeA.value = a } + for (column in compensationPointColumns(a)) { + compose.onNodeWithText(column.title).performScrollTo().assertIsDisplayed() + val layouts = mutableListOf() + compose.onNodeWithText(column.title).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + assertFalse(layouts.single().hasVisualOverflow) + } + } + compose.onNodeWithText("-50").performClick() + compose.runOnIdle { assertEquals(0, clicked) } + } + + @Test fun blankDescriptionCanBeEditedAndSavedWhileEditorStillHasFocus() { + val rows = mutableStateOf(listOf(map(true).copy(description = ""))) + val visible = mutableStateOf(true) + var descriptionInProject = "" + compose.setContent { + if (visible.value) MaterialTheme { + CompensationTable(compensationMapColumns(true), rows.value, "test:edit", + Modifier.size(280.dp, 140.dp), onRowClick = { _, _ -> }, + onDescriptionChange = { _, text -> + descriptionInProject = text + rows.value = listOf(rows.value.single().copy(description = text)) + }) + } + } + compose.onNodeWithTag("test:edit:description:0").performScrollTo().performClick() + compose.onNode(hasSetTextAction()).performTextReplacement("새 보정 설명") + compose.onNode(hasSetTextAction()).assertIsFocused() + compose.runOnIdle { + val project = com.dct.app.domain.model.project.DpcProject( + parameterDescriptions = mapOf("map_TYPE_A:Y" to descriptionInProject)) + val json = com.dct.app.data.repository.DpcProjectManager.serializeProjectToJson(project) + assertEquals("새 보정 설명", com.dct.app.data.repository.DpcProjectManager + .parseProjectFromJson(json, null).parameterDescriptions["map_TYPE_A:Y"]) + } + compose.onNode(hasSetTextAction()).performTextClearance() + compose.runOnIdle { assertEquals("", descriptionInProject); visible.value = false } + compose.runOnIdle { assertEquals("", descriptionInProject) } + } + + @Test fun curvesCoincidentWithEachAxisRemainVisibleForBothTypes() { + val current = mutableStateOf(map(true)) + compose.setContent { + CompensationCurveCanvas(current.value, Modifier.size(300.dp, 200.dp) + .background(Color.White).testTag("edge-curve")) + } + val edges = listOf( + listOf(0 to 0, 50 to 0, 100 to 0), + listOf(0 to 100, 50 to 100, 100 to 100), + listOf(0 to 0, 0 to 50, 100 to 100), + listOf(0 to 0, 100 to 50, 100 to 100) + ) + for (a in listOf(true, false)) for ((edge, points) in edges.withIndex()) { + compose.runOnIdle { + current.value = map(a).copy(points = points.mapIndexed { i, (x, y) -> + MapPoint(i, x, y, if (a) x.toDouble() else y.toDouble()) + }) + } + val pixels = compose.onNodeWithTag("edge-curve").captureToImage().toPixelMap() + val b = compensationPlotBounds(pixels.width.toFloat(), pixels.height.toFloat())!! + var edgePixels = 0 + for (y in 0 until pixels.height) for (x in 0 until pixels.width) { + val c = pixels[x, y] + val colored = if (a) c.red > 0.6f && c.green < 0.4f && c.blue < 0.4f + else c.blue > 0.6f && c.green < 0.4f && c.red < 0.4f + val nearEdge = when (edge) { + 0 -> y >= b.bottom - 2f && y < b.bottom + 1 -> y >= b.top && y < b.top + 2f + 2 -> x >= b.left && x < b.left + 2f + else -> x >= b.right - 2f && x < b.right + } + if (colored && nearEdge) edgePixels++ + } + assertTrue("Type A=$a, edge=$edge must remain visible", edgePixels > 10) + } + } + + @Test fun compensationCurvePixelsNeverEscapePlotFrame() { + val typeA = mutableStateOf(true) + compose.setContent { + CompensationCurveCanvas(map(typeA.value), Modifier.size(300.dp, 200.dp) + .background(Color.White).testTag("curve")) + } + for (a in listOf(true, false)) { + compose.runOnIdle { typeA.value = a } + val pixels = compose.onNodeWithTag("curve").captureToImage().toPixelMap() + val bounds = compensationPlotBounds(pixels.width.toFloat(), pixels.height.toFloat())!! + var curvePixels = 0 + for (y in 0 until pixels.height) for (x in 0 until pixels.width) { + val c = pixels[x, y] + val isCurve = if (a) c.red > 0.6f && c.green < 0.4f && c.blue < 0.4f + else c.blue > 0.6f && c.green < 0.4f && c.red < 0.4f + if (isCurve) { + curvePixels++ + assertTrue("Curve escaped at $x,$y", x >= bounds.left && x < bounds.right && y >= bounds.top && y < bounds.bottom) + } + } + assertTrue("Curve must actually be rendered", curvePixels > 0) + } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ConstantWindowUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ConstantWindowUiTest.kt new file mode 100644 index 0000000..1d8d427 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ConstantWindowUiTest.kt @@ -0,0 +1,115 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.dct.app.domain.model.mdb.* +import com.dct.app.presentation.signaltree.* +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class ConstantWindowUiTest { + @get:Rule val compose = createComposeRule() + private val constant = MdbConstant(1, "Torque", 350.0, 0x1000, 0.0, 500.0, MdbAttrType.ATTR_2_UINT16) + + @Test fun onlyValueCellOpensEditorAndNackPreservesInputUntilRetry() { + var completion: ((String?) -> Unit)? = null + val rows = mutableStateOf(listOf(constant)) + compose.setContent { + MaterialTheme { + WindowsClassicConstantTableWindow(rows.value, modifier = Modifier.size(550.dp, 300.dp), + canRequestDevice = true, onValueChange = { _, _, done -> completion = done }) + } + } + compose.onNodeWithTag("constant:name:1").performTouchInput { click() } + compose.onNodeWithTag("constant:input").assertDoesNotExist() + compose.onNodeWithTag("constant:value:1").performClick() + compose.onNodeWithTag("constant:input").performTextReplacement("400.5") + compose.onNodeWithTag("constant:write").assertIsNotEnabled() + compose.onNodeWithTag("constant:input").performTextReplacement("400") + compose.onNodeWithTag("constant:write").performClick() + compose.onNodeWithText("장비 응답 대기 중").assertIsDisplayed() + compose.onNodeWithTag("constant:write").assertIsNotEnabled() + compose.runOnIdle { completion!!("NACK") } + compose.onNodeWithText("NACK").assertIsDisplayed() + compose.onNodeWithTag("constant:input").assert(SemanticsMatcher.expectValue(androidx.compose.ui.semantics.SemanticsProperties.EditableText, androidx.compose.ui.text.AnnotatedString("400"))) + compose.onNodeWithTag("constant:write").performClick() + compose.runOnIdle { rows.value = listOf(constant.copy(data = 400.0)); completion!!(null) } + compose.onNodeWithText("장비에 값을 반영했습니다.").assertIsDisplayed() + } + + @Test fun deviceButtonsAreDisabledUntilReadyAndReadRefreshesEditor() { + val ready = mutableStateOf(false) + val rows = mutableStateOf(listOf(constant)) + compose.setContent { + MaterialTheme { + WindowsClassicConstantTableWindow(rows.value, modifier = Modifier.size(550.dp, 300.dp), + canRequestDevice = ready.value, onRead = { _, done -> rows.value = listOf(constant.copy(data = 401.0)); done(null) }) + } + } + compose.onNodeWithTag("constant:value:1").performClick() + compose.onNodeWithTag("constant:read").assertIsNotEnabled() + compose.runOnIdle { ready.value = true } + compose.onNodeWithTag("constant:read").performClick() + compose.onNodeWithTag("constant:input").assert(SemanticsMatcher.expectValue(androidx.compose.ui.semantics.SemanticsProperties.EditableText, androidx.compose.ui.text.AnnotatedString("401"))) + } + + @Test fun expandedHeadersFitAtLargeFontAndCheckedFilterRestores() { + var saved = emptyMap() + val visible = mutableStateOf(true) + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 1.5f)) { + if (visible.value) MaterialTheme { + WindowUiStateProvider("constant", saved, { saved = it }) { + WindowsClassicConstantTableWindow(listOf(constant, constant.copy(idx = 2, name = "RPM")), + modifier = Modifier.size(340.dp, 300.dp)) + } + } + } + } + compose.onNodeWithText("Expand").performClick() + for (key in listOf("name", "value", "desc", "min", "max", "addr")) { + compose.onNodeWithTag("constant:header:$key").performScrollTo() + val title = when (key) { "desc" -> "Description"; "addr" -> "Address"; else -> key.replaceFirstChar { it.uppercase() } } + val layouts = mutableListOf() + compose.onNodeWithText(title).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + assertFalse(title, layouts.single().hasVisualOverflow) + } + compose.onNodeWithTag("constant:check:2").performScrollTo().performClick() + compose.onNodeWithTag("constant:only-checked").performClick() + compose.onNodeWithTag("constant:name:1").assertDoesNotExist() + compose.runOnIdle { visible.value = false } + compose.waitForIdle() + compose.runOnIdle { visible.value = true } + compose.onNodeWithText("Collapse").assertExists() + compose.onNodeWithTag("constant:only-checked").assertIsOn() + compose.onNodeWithTag("constant:check:2").assertIsOn() + compose.onNodeWithTag("constant:name:1").assertDoesNotExist() + } + + @Test fun emptyDescriptionCommitsBeforeFocusIsLost() { + val rows = mutableStateOf(listOf(constant)) + var savedDescription = "" + compose.setContent { + MaterialTheme { + WindowsClassicConstantTableWindow(rows.value, modifier = Modifier.size(550.dp, 300.dp), + onDescriptionChange = { _, text -> savedDescription = text; rows.value = listOf(constant.copy(description = text)) }) + } + } + compose.onNodeWithTag("constant:desc:1").performTouchInput { click(center) } + compose.onNode(hasSetTextAction() and isFocused()).performTextReplacement("상수 설명") + compose.runOnIdle { assertEquals("상수 설명", savedDescription) } + compose.onNode(hasSetTextAction() and isFocused()).performTextClearance() + compose.runOnIdle { assertEquals("", savedDescription) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt new file mode 100644 index 0000000..7439db9 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt @@ -0,0 +1,115 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.dct.app.domain.model.chart.* +import com.dct.app.presentation.chart.GraphYAxisDialog +import com.dct.app.presentation.chart.MultiXGraphCanvas +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class GraphSettingsUiTest { + @get:Rule val compose = createComposeRule() + private fun signal() = SeriesRenderData("a", "Signal A", AxisPosition.LEFT_0, 0xFFL, "V", 2f, + interpolationMode = InterpolationMode.LINEAR, isVisible = true, + points = listOf(1000.0 to 150.0, 1001.0 to 200.0), latestValue = 200.0, + minVisibleValue = 150.0, maxVisibleValue = 200.0) + private fun snapshot() = ChartRenderSnapshot(1000.0, 1001.0, listOf(signal()), + mapOf(AxisPosition.LEFT_0 to ChartAxisConfig(AxisPosition.LEFT_0, 0.0, 100.0, false)), + legendPosition = 3, isYAxisAuto = false, isXAxisAuto = false, xAxisPreset = TimeWindowPreset.MANUAL) + + @Composable private fun Menu(state: MutableState, open: MutableState, + onPreset: (TimeWindowPreset) -> Unit = {}) { + GraphContextMenu(expanded = open.value, onDismissRequest = { open.value = false }, + snapshot = state.value, isMultiAxis = true, + onRemoveSignal = {}, onToggleVisibility = { id -> state.value = state.value.copy( + seriesList = state.value.seriesList.map { if (it.signalId == id) it.copy(isVisible = !it.isVisible) else it }) }, + onChangeThickness = { _, _ -> open.value = false }, onChangeDashStyle = { _, _ -> open.value = false }, + onSelectLegendPosition = { state.value = state.value.copy(legendPosition = it); open.value = false }, + onSelectTimePreset = { onPreset(it); open.value = false }, onSelectXAxisManual = {}, + onSelectYAxisAuto = {}, onOpenYAxisSetting = {}, onChangeTitleName = {}, onQuitWindow = {}) + } + + @Test fun autoSubmenuDoesNotApplyUntilSelectionAndReopensAtRoot() { + val state = mutableStateOf(snapshot()) + val open = mutableStateOf(true) + var selected: TimeWindowPreset? = null + compose.setContent { Menu(state, open) { selected = it } } + compose.onNodeWithText("X-Axis").performClick() + compose.onNodeWithText("Auto").performClick() + compose.runOnIdle { assertNull(selected) } + compose.onNodeWithText("10s").assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals(TimeWindowPreset.SEC_10, selected); open.value = true } + compose.onNodeWithText("X-Axis").assertIsDisplayed() + compose.onNodeWithText("Thickness").assertIsDisplayed() + } + + @Test fun showHideKeepsSignalAvailableForReshowAndLegendNoneCanBeSelected() { + val state = mutableStateOf(snapshot()) + val open = mutableStateOf(true) + compose.setContent { Menu(state, open) } + compose.onNodeWithText("Show/Hide").performClick() + compose.onNodeWithText("Signal A").performClick() + compose.runOnIdle { assertFalse(state.value.seriesList.single().isVisible) } + compose.onNodeWithText("Signal A").assertIsDisplayed().performClick() + compose.runOnIdle { assertTrue(state.value.seriesList.single().isVisible); open.value = false } + compose.waitForIdle() + compose.runOnIdle { open.value = true } + compose.onNodeWithText("Legend").performClick() + compose.onNodeWithText("None").performClick() + compose.runOnIdle { assertEquals(3, state.value.legendPosition) } + } + + @Test fun axisDialogRetainsPrecisionAndRejectsNanWithoutDismissing() { + var saved: Pair? = null + compose.setContent { + GraphYAxisDialog("A", .012345, .098765, false, {}, { min,max,_ -> saved = min to max }) + } + compose.onNodeWithText("0.012345").assertExists() + compose.onAllNodes(hasSetTextAction())[1].performTextReplacement("NaN") + compose.onNodeWithText("OK").performClick() + compose.onNodeWithText("Please enter valid numbers.").assertIsDisplayed() + compose.runOnIdle { assertNull(saved) } + compose.onAllNodes(hasSetTextAction())[1].performTextReplacement("0.012345") + compose.onNodeWithText("OK").performClick() + compose.runOnIdle { assertEquals(.012345 to .098765, saved) } + } + + @Test fun outOfRangeCurvesAreClippedInsteadOfFlattenedOntoAxesForBothGraphTypes() { + val state = mutableStateOf(snapshot()) + val multi = mutableStateOf(false) + compose.setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + Box(Modifier.size(400.dp, 240.dp).testTag("graph")) { + MultiXGraphCanvas(state.value, isMultiAxis = multi.value) + } + } + } + fun redPixels(): Int { + val pixels = compose.onNodeWithTag("graph").captureToImage().toPixelMap() + var count = 0 + for (y in 12..216) for (x in 70..380) { + val c = pixels[x,y] + if (c.red > .8f && c.green < .3f && c.blue < .3f) count++ + } + return count + } + for (isMulti in listOf(false,true)) { + compose.runOnIdle { multi.value = isMulti; state.value = snapshot() } + assertEquals(0, redPixels()) + compose.runOnIdle { state.value = snapshot().copy(seriesList = listOf(signal().copy( + points = listOf(1000.0 to 0.0, 1001.0 to 0.0)))) } + assertTrue(redPixels() > 0) // A real on-axis signal must remain visible. + } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiManualConnectionUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiManualConnectionUiTest.kt new file mode 100644 index 0000000..e5b6a24 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiManualConnectionUiTest.kt @@ -0,0 +1,76 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import com.dct.app.data.communication.* +import com.dct.app.presentation.dialog.WifiManualConnectionForm +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class WifiManualConnectionUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun inputsStartEmptyAndPresetButtonsAreAbsent() { + compose.setContent { + MaterialTheme { Box(Modifier.size(480.dp, 500.dp)) { + WifiManualConnectionForm(WifiConnectionInput(), {}, emptyList(), {}, { _, _ -> }) + } } + } + for (tag in listOf("wifi:manual-ip", "wifi:manual-port")) { + compose.onNodeWithTag(tag).assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(""))) + } + compose.onNodeWithTag("wifi:manual-connect").assertIsNotEnabled() + compose.onNodeWithText("SoftAP (192.168.4.1)").assertDoesNotExist() + compose.onNodeWithText("Local (127.0.0.1)").assertDoesNotExist() + } + + @Test fun latestInputRestoresAfterDismissWithoutConnectingAndClearingPersists() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val prefs = context.getSharedPreferences("wifi_ui_test_${System.nanoTime()}", android.content.Context.MODE_PRIVATE) + val visible = mutableStateOf(true) + try { + compose.setContent { + if (visible.value) MaterialTheme { + val store = remember { WifiConnectionPreferences(prefs) } + var input by remember { mutableStateOf(store.load()) } + Box(Modifier.size(480.dp, 500.dp)) { + WifiManualConnectionForm(input, { input = it; store.save(it) }, emptyList(), {}, { _, _ -> fail("No connection expected") }) + } + } + } + compose.onNodeWithTag("wifi:manual-ip").performTextReplacement("192.168.10.8") + compose.onNodeWithTag("wifi:manual-port").performTextReplacement("7777") + compose.runOnIdle { visible.value = false } + compose.waitForIdle() + compose.runOnIdle { visible.value = true } + compose.onNodeWithTag("wifi:manual-ip").assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("192.168.10.8"))) + compose.onNodeWithTag("wifi:manual-port").assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("7777"))) + compose.onNodeWithTag("wifi:manual-port").performTextClearance() + compose.runOnIdle { assertEquals("", WifiConnectionPreferences(prefs).load().port) } + } finally { prefs.edit().clear().commit() } + } + + @Test fun discoveredPcOnlyAppliesOnTapAndConnectionUsesExplicitPort() { + val input = mutableStateOf(WifiConnectionInput()) + var endpoint: Pair? = null + compose.setContent { + MaterialTheme { Box(Modifier.size(480.dp, 500.dp)) { + WifiManualConnectionForm(input.value, { input.value = it }, + listOf(DiscoveredLanServer("Test PC", "192.168.10.8", 7777)), {}, { ip, port -> endpoint = ip to port }) + } } + } + compose.runOnIdle { assertEquals(WifiConnectionInput(), input.value) } + compose.onNodeWithText("클릭하여 IP와 포트 적용").performClick() + compose.onNodeWithTag("wifi:manual-connect").performScrollTo().performClick() + compose.runOnIdle { assertEquals("192.168.10.8" to 7777, endpoint) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowActivationTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowActivationTest.kt new file mode 100644 index 0000000..9119c74 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowActivationTest.kt @@ -0,0 +1,144 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalTestApi::class) +class WindowActivationTest { + @get:Rule val compose = createComposeRule() + + @Test fun childButtonRaisesBackWindowOnlyOnSingleFingerReleaseAndStillClicks() { + var activations = 0 + var clicks = 0 + val backZ = mutableStateOf(0f) + compose.setContent { + MaterialTheme { + Box(Modifier.size(300.dp)) { + Box(Modifier.size(240.dp).zIndex(backZ.value) + .background(Color.White) + .bringWindowToFrontOnPress("back") { activations++; backZ.value = 2f }) { + Button(onClick = { clicks++ }, modifier = Modifier.size(80.dp).testTag("button")) { + Text("Button") + } + } + Box(Modifier.offset(x = 100.dp, y = 50.dp).size(160.dp) + .zIndex(1f).background(Color.Blue) + .bringWindowToFrontOnPress("front") {}) + } + } + } + compose.onNodeWithTag("button").performTouchInput { down(center) } + compose.runOnIdle { + assertEquals(0, activations) + assertEquals(0f, backZ.value) + assertEquals(0, clicks) + } + compose.onNodeWithTag("button").performTouchInput { up() } + compose.runOnIdle { assertEquals(1, clicks); assertEquals(1, activations); assertEquals(2f, backZ.value) } + } + + @Test fun scrollingWindowBodyStillScrollsAndActivatesOnlyOnce() { + var activations = 0 + lateinit var scroll: androidx.compose.foundation.ScrollState + compose.setContent { + scroll = rememberScrollState() + Box(Modifier.size(240.dp).bringWindowToFrontOnPress("window") { activations++ }) { + Column(Modifier.fillMaxSize().verticalScroll(scroll).testTag("scroll")) { + Spacer(Modifier.height(2000.dp).fillMaxWidth()) + } + } + } + compose.onNodeWithTag("scroll").performTouchInput { swipeUp() } + compose.runOnIdle { + assertEquals(1, activations) + assertTrue(scroll.value > 0) + } + } + + @Test fun textFieldCanReceiveFocusAndInputAfterWindowActivation() { + var activations = 0 + val value = mutableStateOf("") + compose.setContent { + Box(Modifier.size(240.dp).bringWindowToFrontOnPress("window") { activations++ }) { + BasicTextField(value.value, onValueChange = { value.value = it }, + modifier = Modifier.size(180.dp, 50.dp).testTag("input")) + } + } + compose.onNodeWithTag("input").performTouchInput { click() } + compose.onNodeWithTag("input").assertIsFocused().performTextInput("RPM") + compose.runOnIdle { assertEquals(1, activations); assertEquals("RPM", value.value) } + } + + @Test fun hoveringDoesNotRaiseAWindow() { + var activations = 0 + compose.setContent { + Box(Modifier.size(200.dp).testTag("window") + .bringWindowToFrontOnPress("window") { activations++ }) + } + compose.onNodeWithTag("window").performMouseInput { enter(center); moveTo(center); exit() } + compose.runOnIdle { assertEquals(0, activations) } + } + + @Test fun fingersOnDifferentWindowsNeverChangeZOrderEvenWithoutMovement() { + val raised = mutableStateOf("") + compose.setContent { + val navigation = remember { WorkspaceTouchNavigation() } + val x = remember { ScrollState(0) } + val y = remember { ScrollState(0) } + CompositionLocalProvider(LocalWorkspaceTouchNavigation provides navigation) { + Box(Modifier.size(300.dp, 200.dp).testTag("workspace") + .workspaceTouchNavigation(navigation, x, y)) { + Box(Modifier.size(140.dp, 200.dp).zIndex(if (raised.value == "back") 3f else 0f) + .testTag("back").bringWindowToFrontOnPress("back") { raised.value = "back" }) + Box(Modifier.offset(x = 160.dp).size(140.dp, 200.dp) + .zIndex(if (raised.value == "front") 3f else 1f) + .bringWindowToFrontOnPress("front") { raised.value = "front" }) + } + } + } + compose.onNodeWithTag("workspace").performTouchInput { + down(0, Offset(width * .2f, height * .5f)) + } + compose.runOnIdle { assertEquals("", raised.value) } + compose.onNodeWithTag("workspace").performTouchInput { + down(1, Offset(width * .8f, height * .5f)) + up(0) + } + compose.runOnIdle { assertEquals("", raised.value) } + compose.onNodeWithTag("workspace").performTouchInput { up(1) } + compose.runOnIdle { assertEquals("", raised.value) } + compose.onNodeWithTag("back").performTouchInput { advanceEventTime(500); click() } + compose.runOnIdle { assertEquals("back", raised.value) } + } + + @Test fun mousePressStillActivatesImmediatelyWithoutWaitingForRelease() { + var activations = 0 + compose.setContent { + Box(Modifier.size(200.dp).testTag("window") + .bringWindowToFrontOnPress("window") { activations++ }) + } + compose.onNodeWithTag("window").performMouseInput { moveTo(center); press() } + compose.runOnIdle { assertEquals(1, activations) } + compose.onNodeWithTag("window").performMouseInput { release() } + compose.runOnIdle { assertEquals(1, activations) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowParityUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowParityUiTest.kt new file mode 100644 index 0000000..9e9866b --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowParityUiTest.kt @@ -0,0 +1,76 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import com.dct.app.domain.model.chart.* +import com.dct.app.presentation.signaltree.* +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class WindowParityUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun actualGraphContentForwardsTapToCursorCallback() { + var cursor: Double? = null + compose.setContent { + MaterialTheme { + Box(Modifier.size(600.dp, 300.dp).testTag("graph")) { + GraphWindowContent( + snapshot = ChartRenderSnapshot(0.0, 10.0, emptyList(), emptyMap(), legendPosition = 3), + windowId = "test", onRemoveSignal = {}, onClearAll = {}, + onCrosshairTimeChange = { cursor = it } + ) + } + } + } + compose.onNodeWithTag("graph").performTouchInput { click(center) } + compose.runOnIdle { assertNotNull(cursor); assertTrue(cursor!! in 0.0..10.0) } + } + + @Test fun headerDividerDragResizesWithoutActivatingSort() { + val width = mutableStateOf(160.dp) + var sorts = 0 + compose.setContent { + Box(Modifier.size(400.dp, 60.dp)) { + ResizableTableHeaderCell("Name", width.value, {}, Modifier.testTag("header"), + onWidthChange = { width.value = it }, onClick = { sorts++ }) + } + } + compose.onNodeWithTag("header").performTouchInput { + swipe(Offset(right - 3f, centerY), Offset(right + 70f, centerY), 300) + } + compose.runOnIdle { assertTrue(width.value > 160.dp); assertEquals(0, sorts) } + } + + @Test fun windowPreferencesAreIsolatedAndRestoredAfterDisposal() { + var saved = emptyMap() + val visible = mutableStateOf(true) + lateinit var search: MutableState + lateinit var expanded: MutableState + lateinit var checked: MutableState> + compose.setContent { + if (visible.value) { + WindowUiStateProvider("one", saved, { saved = it }) { + search = rememberWindowString("search") + expanded = rememberWindowBoolean("expanded") + checked = rememberWindowChecked("checked") + Text(search.value, Modifier.testTag("value")) + } + } + } + compose.runOnIdle { search.value = "RPM"; expanded.value = true; checked.value = setOf(1L, 2L) } + compose.runOnIdle { visible.value = false } + compose.waitForIdle() + compose.runOnIdle { visible.value = true } + compose.onNodeWithTag("value").assertTextEquals("RPM") + compose.runOnIdle { assertTrue(expanded.value); assertEquals(setOf(1L, 2L), checked.value) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt new file mode 100644 index 0000000..d53cfe4 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt @@ -0,0 +1,231 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import com.dct.app.domain.model.chart.ChartRenderSnapshot +import com.dct.app.presentation.chart.MultiXGraphCanvas +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class WorkspaceTouchNavigationUiTest { + @get:Rule val compose = createComposeRule() + private lateinit var x: ScrollState + private lateinit var y: ScrollState + private var zooms = 0 + private var pans = 0 + private var taps = 0 + private var drags = 0 + private var activations = 0 + private lateinit var innerScroll: ScrollState + + private fun content(graph: Boolean = false, multi: Boolean = false, header: Boolean = false, table: Boolean = false) { + compose.setContent { + x = remember { ScrollState(150) } + y = remember { ScrollState(150) } + innerScroll = remember { ScrollState(10) } + val navigation = remember { WorkspaceTouchNavigation() } + val horizontalFling = ScrollableDefaults.flingBehavior() + val verticalFling = ScrollableDefaults.flingBehavior() + CompositionLocalProvider(LocalWorkspaceTouchNavigation provides navigation) { + Box(Modifier.size(400.dp, 300.dp).testTag("viewport") + .workspaceTouchNavigation(navigation, x, y, horizontalFling, verticalFling) + .horizontalScroll(x, flingBehavior = horizontalFling) + .verticalScroll(y, flingBehavior = verticalFling)) { + Box(Modifier.size(900.dp, 700.dp).bringWindowToFrontOnPress("window") { activations++ }) { + if (graph) { + MultiXGraphCanvas(ChartRenderSnapshot(0.0, 60.0, emptyList(), emptyMap(), legendPosition = 3), + Modifier.size(900.dp, 700.dp), isMultiAxis = multi, + onPanTime = { pans++ }, onZoomTime = { _, _ -> zooms++ }, + onCrosshairTimeChange = { taps++ }) + } else if (table) { + val stopNested = remember { object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource) = available + } } + Box(Modifier.size(900.dp, 700.dp).nestedScroll(stopNested).verticalScroll(innerScroll)) { + Box(Modifier.size(900.dp, 1400.dp).clickable { taps++ }) + } + } else if (header) { + Box(Modifier.size(900.dp, 700.dp).pointerInput(Unit) { + detectDragGestures { change, _ -> change.consume(); drags++ } + }) + } else { + Box(Modifier.size(900.dp, 700.dp).clickable { taps++ }) + } + } + } + } + } + } + + private fun translateWithOneFingerTail() { + compose.onNodeWithTag("viewport").performTouchInput { + val a = center - Offset(50f, 0f) + val b = center + Offset(50f, 0f) + down(0, a); down(1, b) + for (i in 1..5) { + updatePointerTo(0, a - Offset(i * 15f, i * 10f)) + updatePointerTo(1, b - Offset(i * 15f, i * 10f)) + move() + } + up(1) + } + val afterX = x.value + val afterY = y.value + compose.onNodeWithTag("viewport").performTouchInput { + moveBy(0, Offset(-60f, -60f)); up(0) + } + compose.runOnIdle { + assertEquals(afterX, x.value); assertEquals(afterY, y.value) + assertTrue(x.value > 150); assertTrue(y.value > 150) + assertEquals(0, taps); assertEquals(0, pans); assertEquals(0, zooms) + assertEquals(0, activations) + } + } + + @Test fun twoFingerTranslationOverClickableContentScrollsBothAxesWithoutClickOrTail() { + content() + translateWithOneFingerTail() + } + @Test fun ordinaryGraphTranslationMovesCanvasNotTimeAxisOrScale() { + content(graph = true) + translateWithOneFingerTail() + } + @Test fun multiAxisGraphTranslationMovesCanvasNotTimeAxisOrScale() { + content(graph = true, multi = true) + translateWithOneFingerTail() + } + @Test fun twoFingersOverHeaderCannotMoveWindow() { + content(header = true) + translateWithOneFingerTail() + compose.runOnIdle { assertEquals(0, drags) } + } + @Test fun tableNestedScrollBarrierDoesNotBlockTwoFingerCanvasNavigation() { + content(table = true) + translateWithOneFingerTail() + compose.runOnIdle { assertEquals(10, innerScroll.value) } + } + @Test fun canvasFlingStopsAtTopLeftBoundary() { + content() + compose.onNodeWithTag("viewport").performTouchInput { + val a = Offset(30f, 30f) + val b = Offset(90f, 30f) + down(0, a); down(1, b) + for (i in 1..12) { + updatePointerTo(0, a + Offset(i * 20f, i * 20f)) + updatePointerTo(1, b + Offset(i * 20f, i * 20f)) + move() + } + up(0); up(1) + } + compose.runOnIdle { assertEquals(0, x.value); assertEquals(0, y.value) } + compose.mainClock.advanceTimeBy(1000) + compose.runOnIdle { assertEquals(0, x.value); assertEquals(0, y.value) } + } + @Test fun thirdFingerEndsNavigationUntilAllContactsLift() { + content() + compose.onNodeWithTag("viewport").performTouchInput { + down(0, center - Offset(50f, 0f)); down(1, center + Offset(50f, 0f)) + down(2, center + Offset(0f, 60f)); up(2) + updatePointerTo(0, center - Offset(100f, 50f)) + updatePointerTo(1, center - Offset(0f, 50f)) + move(); up(1); up(0) + } + compose.runOnIdle { assertEquals(150, x.value); assertEquals(150, y.value); assertEquals(0, taps); assertEquals(0, activations) } + } + @Test fun pinchOverGraphChangesScaleWithoutMovingCanvasOrClicking() { + content(graph = true) + compose.onNodeWithTag("viewport").performTouchInput { + down(0, center - Offset(30f, 0f)); down(1, center + Offset(30f, 0f)) + for (i in 1..5) { + updatePointerTo(0, center - Offset(30f + i * 12f, 0f)) + updatePointerTo(1, center + Offset(30f + i * 12f, 0f)) + move() + } + up(0); up(1) + } + compose.runOnIdle { + assertTrue(zooms > 0); assertEquals(0, pans); assertEquals(0, taps) + assertEquals(0, activations) + assertEquals(150, x.value); assertEquals(150, y.value) + } + } + @Test fun establishedWindowDragIsNotCancelledBySecondFinger() { + content(header = true) + compose.onNodeWithTag("viewport").performTouchInput { + down(0, center) + moveTo(0, center + Offset(60f, 0f)) + down(1, center - Offset(50f, 0f)) + updatePointerTo(0, center + Offset(100f, 0f)) + updatePointerTo(1, center - Offset(10f, 0f)) + move(); up(1); up(0) + } + compose.runOnIdle { + assertTrue(drags > 0); assertEquals(150, x.value); assertEquals(150, y.value) + assertEquals(1, activations) + } + } + + private fun fastTwoFingerSwipe() { + compose.onNodeWithTag("viewport").performTouchInput { + val a = center - Offset(50f, 0f) + val b = center + Offset(50f, 0f) + down(0, a); down(1, b) + for (i in 1..8) { + updatePointerTo(0, a - Offset(i * 12f, i * 6f)) + updatePointerTo(1, b - Offset(i * 12f, i * 6f)) + move(delayMillis = 10) + } + up(0); up(1) + } + } + + @Test fun twoFingerReleaseFlingsBothAxesWithoutActivatingWindow() { + content() + compose.mainClock.autoAdvance = false + fastTwoFingerSwipe() + val releasedX = x.value + val releasedY = y.value + compose.mainClock.advanceTimeBy(200) + compose.runOnIdle { + assertTrue(x.value > releasedX); assertTrue(y.value > releasedY) + assertEquals(0, activations); assertEquals(0, taps) + } + compose.mainClock.advanceTimeBy(5000) + compose.runOnIdle { assertFalse(x.isScrollInProgress); assertFalse(y.isScrollInProgress) } + } + + @Test fun newTouchOverWindowStopsTwoFingerInertiaImmediately() { + content() + compose.mainClock.autoAdvance = false + fastTwoFingerSwipe() + compose.mainClock.advanceTimeBy(80) + compose.onNodeWithTag("viewport").performTouchInput { down(center) } + val stoppedX = x.value + val stoppedY = y.value + compose.mainClock.advanceTimeBy(200) + compose.runOnIdle { + assertEquals(stoppedX, x.value); assertEquals(stoppedY, y.value) + assertEquals(0, activations) + } + compose.onNodeWithTag("viewport").performTouchInput { up() } + } +} diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..18fbfa7 --- /dev/null +++ b/Android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/java/com/dct/app/core/common/BaseViewModel.kt b/Android/app/src/main/java/com/dct/app/core/common/BaseViewModel.kt new file mode 100644 index 0000000..fe14b5e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/common/BaseViewModel.kt @@ -0,0 +1,65 @@ +package com.dct.app.core.common + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +interface UiState +interface UiEvent +interface UiEffect + +abstract class BaseViewModel( + initialState: State, + protected val dispatchers: DispatcherProvider +) : ViewModel() { + + private val _uiState = MutableStateFlow(initialState) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEffect = Channel(Channel.BUFFERED) + val uiEffect: Flow = _uiEffect.receiveAsFlow() + + protected val currentState: State get() = _uiState.value + + protected val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + handleException(throwable) + } + + abstract fun onEvent(event: Event) + + protected fun updateState(reducer: State.() -> State) { + _uiState.update(reducer) + } + + protected fun sendEffect(effect: Effect) { + viewModelScope.launch(dispatchers.mainImmediate) { + _uiEffect.send(effect) + } + } + + protected fun launchOnIO( + block: suspend CoroutineScope.() -> Unit + ): Job { + return viewModelScope.launch(dispatchers.io + exceptionHandler, block = block) + } + + protected fun launchOnDefault( + block: suspend CoroutineScope.() -> Unit + ): Job { + return viewModelScope.launch(dispatchers.default + exceptionHandler, block = block) + } + + protected open fun handleException(throwable: Throwable) { + // 기본 에러 로깅 및 하위 ViewModel 재정의 지점 + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/common/DctResult.kt b/Android/app/src/main/java/com/dct/app/core/common/DctResult.kt new file mode 100644 index 0000000..b8e02b8 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/common/DctResult.kt @@ -0,0 +1,45 @@ +package com.dct.app.core.common + +import com.dct.app.core.error.DctError + +sealed interface DctResult { + data class Success(val data: T) : DctResult + data class Failure(val error: DctError, val cause: Throwable? = null) : DctResult + object Loading : DctResult + + val isSuccess: Boolean get() = this is Success + val isFailure: Boolean get() = this is Failure + val isLoading: Boolean get() = this is Loading +} + +fun DctResult.getOrNull(): T? = when (this) { + is DctResult.Success -> data + else -> null +} + +fun DctResult.getOrElse(defaultValue: T): T = when (this) { + is DctResult.Success -> data + else -> defaultValue +} + +inline fun DctResult.map(transform: (T) -> R): DctResult = when (this) { + is DctResult.Success -> DctResult.Success(transform(data)) + is DctResult.Failure -> DctResult.Failure(error, cause) + is DctResult.Loading -> DctResult.Loading +} + +inline fun DctResult.flatMap(transform: (T) -> DctResult): DctResult = when (this) { + is DctResult.Success -> transform(data) + is DctResult.Failure -> DctResult.Failure(error, cause) + is DctResult.Loading -> DctResult.Loading +} + +inline fun DctResult.onSuccess(action: (T) -> Unit): DctResult { + if (this is DctResult.Success) action(data) + return this +} + +inline fun DctResult.onFailure(action: (DctError, Throwable?) -> Unit): DctResult { + if (this is DctResult.Failure) action(error, cause) + return this +} diff --git a/Android/app/src/main/java/com/dct/app/core/common/DefaultDispatcherProvider.kt b/Android/app/src/main/java/com/dct/app/core/common/DefaultDispatcherProvider.kt new file mode 100644 index 0000000..5f9ac29 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/common/DefaultDispatcherProvider.kt @@ -0,0 +1,12 @@ +package com.dct.app.core.common + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +class DefaultDispatcherProvider : DispatcherProvider { + override val main: CoroutineDispatcher get() = Dispatchers.Main + override val mainImmediate: CoroutineDispatcher get() = Dispatchers.Main.immediate + override val io: CoroutineDispatcher get() = Dispatchers.IO + override val default: CoroutineDispatcher get() = Dispatchers.Default + override val unconfined: CoroutineDispatcher get() = Dispatchers.Unconfined +} diff --git a/Android/app/src/main/java/com/dct/app/core/common/DispatcherProvider.kt b/Android/app/src/main/java/com/dct/app/core/common/DispatcherProvider.kt new file mode 100644 index 0000000..27caafa --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/common/DispatcherProvider.kt @@ -0,0 +1,11 @@ +package com.dct.app.core.common + +import kotlinx.coroutines.CoroutineDispatcher + +interface DispatcherProvider { + val main: CoroutineDispatcher + val mainImmediate: CoroutineDispatcher + val io: CoroutineDispatcher + val default: CoroutineDispatcher + val unconfined: CoroutineDispatcher +} diff --git a/Android/app/src/main/java/com/dct/app/core/common/EventBus.kt b/Android/app/src/main/java/com/dct/app/core/common/EventBus.kt new file mode 100644 index 0000000..a97fc73 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/common/EventBus.kt @@ -0,0 +1,25 @@ +package com.dct.app.core.common + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +sealed interface AppEvent { + data class ConnectionStatusChanged(val isConnected: Boolean, val endpoint: String) : AppEvent + data class MdbLoaded(val fileName: String, val signalCount: Int) : AppEvent + data class ErrorOccurred(val message: String, val isFatal: Boolean) : AppEvent + data class CalibrationUpdated(val name: String, val newValue: Double) : AppEvent +} + +class EventBus { + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _events.asSharedFlow() + + suspend fun emit(event: AppEvent) { + _events.emit(event) + } + + fun tryEmit(event: AppEvent): Boolean { + return _events.tryEmit(event) + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/common/PacketDistributor.kt b/Android/app/src/main/java/com/dct/app/core/common/PacketDistributor.kt new file mode 100644 index 0000000..8cdc5b2 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/common/PacketDistributor.kt @@ -0,0 +1,21 @@ +package com.dct.app.core.common + +import com.dct.app.domain.model.protocol.DctPacket +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class PacketDistributor { + // 고속 패킷 수신 스트림 (256 버퍼 유지, 오버플로우 시 가장 오래된 패킷 drop) + private val _packetStream = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 256, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val packetStream: SharedFlow = _packetStream.asSharedFlow() + + fun dispatchPacket(packet: DctPacket) { + _packetStream.tryEmit(packet) + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt b/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt new file mode 100644 index 0000000..974de15 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt @@ -0,0 +1,118 @@ +package com.dct.app.core.engine + +import com.dct.app.data.parser.A2lParser +import com.dct.app.data.protocol.DctStreamPayloadParser +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.mdb.SignalDefinition + +/** + * XCP DAQ 실시간 바이트 스트림 고속 디코더 엔진 + * PC 버전 ProcessXCPMeasurementData (MainFrm.cpp:14980-15080) 100% 호환 + * - 0xD0 (SEND_XCP_MEASUREMENT_DATA) 바이너리 페이로드 고속 역직렬화 + * - ASAM 12대 A2lDataType (UBYTE, UWORD, ULONG, FLOAT32 등) 및 Endian 완벽 언패킹 + * - CompuMethod (Linear, Rational Function, Tab Intp) 실시간 물리값 변환 + */ +class XcpDaqStreamDecoder { + + private val configuredSignals = mutableListOf() + private val configuredDataTypes = mutableListOf() + private val configuredCompuMethods = mutableListOf() + private var isMsbFirst: Boolean = false + + sealed interface DecodeResult { + data class Success(val values: Map) : DecodeResult + data class Failure(val reason: String) : DecodeResult + } + + /** + * 계측 대상 A2L 신호 목록 및 메타데이터 구성 + */ + @Synchronized + fun configureDaqSignals( + signals: List, + rawMeasurements: List = emptyList(), + compuMethods: Map = emptyMap(), + isMsbFirst: Boolean = false + ) { + configuredSignals.clear() + configuredSignals.addAll(signals) + configuredDataTypes.clear() + configuredCompuMethods.clear() + this.isMsbFirst = isMsbFirst + + val measMap = rawMeasurements.associateBy { it.name.lowercase() } + for (sig in signals) { + val baseName = sig.name.substringBefore('[').lowercase() + val rawMeas = measMap[baseName] + val dataType = rawMeas?.a2lDataType ?: sig.a2lDataType ?: when (sig.attr.byteSize) { + 1 -> if (sig.attr.isSigned) A2lDataType.SBYTE else A2lDataType.UBYTE + 2 -> if (sig.attr.isSigned) A2lDataType.SWORD else A2lDataType.UWORD + 4 -> if (sig.attr.isSigned) A2lDataType.SLONG else A2lDataType.ULONG + else -> A2lDataType.UBYTE + } + configuredDataTypes += dataType + + val compuName = rawMeas?.conversionMethod?.lowercase() ?: "" + configuredCompuMethods += compuMethods[compuName] + } + } + + /** + * 0xD0 실시간 DAQ 페이로드 디코딩 + * @return 신호명 및 "a2l_{adl}" 키를 포함하는 물리값 맵 + */ + fun decodeDaqPayload(payload: ByteArray): Map = + when (val result = decodeDaqPayloadResult(payload)) { + is DecodeResult.Success -> result.values + is DecodeResult.Failure -> emptyMap() + } + + /** 잘린 패킷, 선언 개수 불일치, 타입 순서 불일치를 부분 적용 없이 거절합니다. */ + @Synchronized + fun decodeDaqPayloadResult(payload: ByteArray): DecodeResult { + if (configuredSignals.isEmpty()) return DecodeResult.Failure("XCP 측정 신호가 설정되지 않았습니다.") + val stream = DctStreamPayloadParser.parseXcpStream(payload) + ?: return DecodeResult.Failure("XCP D0 payload 구조가 올바르지 않습니다.") + if (stream.values.size != configuredSignals.size) { + return DecodeResult.Failure( + "XCP 측정 개수가 다릅니다. 설정=${configuredSignals.size}, 수신=${stream.values.size}" + ) + } + + val resultMap = HashMap(configuredSignals.size * 2) + for (index in configuredSignals.indices) { + val sig = configuredSignals[index] + val expectedType = configuredDataTypes[index] + val received = stream.values[index] + if (received.dataType != expectedType) { + return DecodeResult.Failure( + "XCP 타입이 다릅니다. index=$index, 설정=${expectedType.typeName}, 수신=${received.dataType.typeName}" + ) + } + + val raw = received.dataType.unpackRawValue(received.data, 0, isMsbFirst) + val compu = configuredCompuMethods[index] + val phy = if (compu != null) { + compu.calculatePhysicalValue(raw) + } else { + sig.calculatePhysicalValue(raw) + } + + resultMap[sig.name] = phy + resultMap["a2l_${sig.adl}"] = phy + resultMap["sig_0_${sig.idx}"] = phy + } + + return DecodeResult.Success(resultMap) + } + + @Synchronized + fun isConfigured(): Boolean = configuredSignals.isNotEmpty() + + @Synchronized + fun clear() { + configuredSignals.clear() + configuredDataTypes.clear() + configuredCompuMethods.clear() + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/error/DctError.kt b/Android/app/src/main/java/com/dct/app/core/error/DctError.kt new file mode 100644 index 0000000..faa8ca3 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/error/DctError.kt @@ -0,0 +1,44 @@ +package com.dct.app.core.error + +sealed class DctError(val message: String, val code: Int) { + // 1000: 프로토콜 및 통신 오류 + sealed class ProtocolError(message: String, code: Int) : DctError(message, code) { + data class ChecksumMismatch(val expected: Byte, val actual: Byte) : + ProtocolError("체크섬 검증 실패 (기대값: 0x${"%02X".format(expected)}, 실제값: 0x${"%02X".format(actual)})", 1001) + data class InvalidFrameHeader(val header: Byte) : + ProtocolError("유효하지 않은 헤더 바이트: 0x${"%02X".format(header)}", 1002) + data class BufferOverflow(val size: Int, val maxCapacity: Int) : + ProtocolError("수신 링버퍼 용량 초과: $size / $maxCapacity", 1003) + data class ConnectionFailed(val reason: String) : + ProtocolError("장비 연결 실패: $reason", 1004) + object Disconnected : + ProtocolError("연결이 종료되었습니다.", 1005) + } + + // 2000: MDB 데이터베이스 오류 + sealed class MdbError(message: String, code: Int) : DctError(message, code) { + data class FileNotFound(val path: String) : + MdbError("MDB 파일을 찾을 수 없습니다: $path", 2001) + data class TableNotFound(val tableName: String) : + MdbError("필수 테이블이 존재하지 않습니다: $tableName", 2002) + data class SchemaMismatch(val column: String) : + MdbError("테이블 스키마 불일치 컬럼: $column", 2003) + data class SafAccessDenied(val uri: String) : + MdbError("SAF 스토리지 접근 권한이 거부되었습니다: $uri", 2004) + data class CorruptedDatabase(val detail: String) : + MdbError("MDB 데이터베이스 파일이 손상되었습니다: $detail", 2005) + } + + // 3000: 신호 파싱 및 캘리브레이션 오류 + sealed class SignalError(message: String, code: Int) : DctError(message, code) { + data class UnknownAttribute(val attr: Int) : + SignalError("지원하지 않는 신호 ATTR 타입: $attr", 3001) + data class PayloadIndexOutOfBounds(val offset: Int, val size: Int) : + SignalError("패킷 페이로드 오프셋 초과: offset=$offset, size=$size", 3002) + data class ConstantOutOfRange(val name: String, val value: Double, val min: Double, val max: Double) : + SignalError("상수 '$name' 값($value)이 허용 범위 [$min ~ $max]를 벗어났습니다.", 3003) + } + + // 9000: 일반 시스템 오류 + data class Unknown(val detail: String) : DctError("알 수 없는 시스템 오류: $detail", 9999) +} diff --git a/Android/app/src/main/java/com/dct/app/core/formula/FormulaEvaluator.kt b/Android/app/src/main/java/com/dct/app/core/formula/FormulaEvaluator.kt new file mode 100644 index 0000000..fb33845 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/formula/FormulaEvaluator.kt @@ -0,0 +1,366 @@ +package com.dct.app.core.formula + +import java.util.ArrayDeque +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.* + +/** + * 고성능 수식 연산 엔진 (Shunting-Yard & RPN Evaluator) + * PC 레거시 MTParser 호환: 사칙연산, 삼각함수, 수학함수, 신호 변수 치환 지원 + */ +object FormulaEvaluator { + + private sealed interface Token { + data class Number(val value: Double) : Token + data class Variable(val name: String) : Token + data class Operator( + val symbol: Char, + val precedence: Int, + val isRightAssociative: Boolean, + val operandCount: Int = 2 + ) : Token + data class Function(val name: String, val argCount: Int = 1) : Token + data object LeftParen : Token + data object RightParen : Token + data object Comma : Token + } + + // 표현식별 파싱된 RPN 토큰 캐시 (실시간 계측 중 파싱 오버헤드 제거) + private val compiledCache = ConcurrentHashMap>() + + /** + * 수식을 평가하여 결과값을 반환합니다. + * @param expression 수학 수식 표현식 (예: "\"Torque\" * \"EngineSpeed\" / 9549" 또는 "sin(deg) * 100") + * @param variables 신호명 및 변수값 맵 + * @return 계산 결과 Double (오류 발생 시 Double.NaN) + */ + fun evaluate(expression: String, variables: Map): Double { + if (expression.isBlank()) return Double.NaN + val trimmed = expression.trim() + if (trimmed.startsWith("LIN(", ignoreCase = true) && trimmed.endsWith(")")) { + return SMLinEvaluator.evaluate(trimmed, variables) + } + val rpn = compiledCache.computeIfAbsent(expression) { expr -> + try { + val tokens = tokenize(expr) + val r = toRpn(tokens) + if (isRpnValid(r)) r else emptyList() + } catch (e: Exception) { + emptyList() + } + } + if (rpn.isEmpty()) return Double.NaN + return evaluateRpn(rpn, variables) + } + + /** + * 수식의 문법 유효성을 사전 검증합니다. + */ + fun validate(expression: String): Boolean { + if (expression.isBlank()) return false + val trimmed = expression.trim() + if (trimmed.startsWith("LIN(", ignoreCase = true) && trimmed.endsWith(")")) { + val testVars = mapOf("01-01 ch" to 1.0, "01-01" to 1.0, "02-01 ch" to 1.0) + return !SMLinEvaluator.evaluate(trimmed, testVars).isNaN() + } + return try { + val tokens = tokenize(expression) + val rpn = toRpn(tokens) + isRpnValid(rpn) + } catch (e: Exception) { + false + } + } + + /** + * 캐시를 초기화합니다. + */ + fun clearCache() { + compiledCache.clear() + } + + private fun isRpnValid(rpn: List): Boolean { + if (rpn.isEmpty()) return false + var stackDepth = 0 + for (token in rpn) { + when (token) { + is Token.Number, is Token.Variable -> stackDepth++ + is Token.Operator -> { + if (stackDepth < token.operandCount) return false + stackDepth -= (token.operandCount - 1) + } + is Token.Function -> { + if (stackDepth < token.argCount) return false + stackDepth -= (token.argCount - 1) + } + else -> return false + } + } + return stackDepth == 1 + } + + private fun tokenize(expr: String): List { + val tokens = mutableListOf() + var i = 0 + val len = expr.length + + var expectUnary = true + + while (i < len) { + val ch = expr[i] + + if (ch.isWhitespace()) { + i++ + continue + } + + // 1. 큰따옴표 또는 작은따옴표로 감싸진 변수명: "EngineSpeed", 'Torque' + if (ch == '"' || ch == '\'') { + val quote = ch + val start = i + 1 + i++ + while (i < len && expr[i] != quote) { + i++ + } + if (i >= len) throw IllegalArgumentException("Unclosed quoted variable") + val varName = expr.substring(start, i) + i++ + tokens.add(Token.Variable(varName)) + expectUnary = false + continue + } + + // 2. 단항 마이너스 / 플러스 + if ((ch == '-' || ch == '+') && expectUnary) { + if (ch == '-') { + // 지수 연산과 같은 우선순위의 우결합 단항 연산자. 2 * -3, 2 ^ -3을 정확히 처리한다. + tokens.add(Token.Operator('~', 3, true, operandCount = 1)) + } + i++ + continue + } + + // 3. 숫자 리터럴 (정수, 소수, 지수 표기법) + if (ch.isDigit() || (ch == '.' && i + 1 < len && expr[i + 1].isDigit())) { + val start = i + while (i < len && (expr[i].isDigit() || expr[i] == '.' || expr[i] == 'e' || expr[i] == 'E' || + ( (expr[i] == '+' || expr[i] == '-') && i > start && (expr[i - 1] == 'e' || expr[i - 1] == 'E') ))) { + i++ + } + val numStr = expr.substring(start, i) + val num = numStr.toDoubleOrNull() ?: throw IllegalArgumentException("Invalid number: $numStr") + tokens.add(Token.Number(num)) + expectUnary = false + continue + } + + // 4. 연산자 + if (ch == '+' || ch == '-' || ch == '*' || ch == '/' || ch == '%' || ch == '^') { + if (expectUnary && ch != '+' && ch != '-') { + throw IllegalArgumentException("Unexpected binary operator without operand: $ch") + } + val precedence = when (ch) { + '+', '-' -> 1 + '*', '/', '%' -> 2 + '^' -> 3 + else -> 0 + } + val isRightAssociative = (ch == '^') + tokens.add(Token.Operator(ch, precedence, isRightAssociative)) + expectUnary = true + i++ + continue + } + + // 5. 괄호 및 쉼표 + if (ch == '(') { + tokens.add(Token.LeftParen) + expectUnary = true + i++ + continue + } + if (ch == ')') { + tokens.add(Token.RightParen) + expectUnary = false + i++ + continue + } + if (ch == ',') { + tokens.add(Token.Comma) + expectUnary = true + i++ + continue + } + + // 6. 함수명, 상수, 또는 일반 변수명 식별자 (알파벳, 밑줄 등) + if (ch.isLetter() || ch == '_' || ch == '$') { + val start = i + while (i < len && (expr[i].isLetterOrDigit() || expr[i] == '_' || expr[i] == '$' || expr[i] == '.')) { + i++ + } + val name = expr.substring(start, i) + val lower = name.lowercase(Locale.US) + + // 알려진 함수 확인 + val funcToken = when (lower) { + "sin", "cos", "tan", "asin", "acos", "atan", + "sinh", "cosh", "tanh", "sqrt", "cbrt", "abs", + "log", "ln", "log2", "exp", "round", "floor", "ceil", + "rad", "deg" -> Token.Function(lower, 1) + "pow", "min", "max", "atan2" -> Token.Function(lower, 2) + "pi" -> Token.Number(PI) + "e" -> Token.Number(E) + else -> Token.Variable(name) + } + tokens.add(funcToken) + expectUnary = (funcToken is Token.Function) + continue + } + + throw IllegalArgumentException("Unsupported character: $ch") + } + + if (expectUnary && tokens.isNotEmpty() && tokens.last() !is Token.RightParen) { + throw IllegalArgumentException("Incomplete expression ending with operator") + } + + return tokens + } + + private fun toRpn(tokens: List): List { + val output = mutableListOf() + val operatorStack = ArrayDeque() + + for (token in tokens) { + when (token) { + is Token.Number, is Token.Variable -> output.add(token) + is Token.Function -> operatorStack.push(token) + is Token.Comma -> { + while (operatorStack.isNotEmpty() && operatorStack.peek() !is Token.LeftParen) { + output.add(operatorStack.pop()) + } + if (operatorStack.isEmpty()) throw IllegalArgumentException("Comma outside function call") + } + is Token.Operator -> { + while (operatorStack.isNotEmpty()) { + val top = operatorStack.peek() + if (top is Token.Operator) { + if ((!token.isRightAssociative && token.precedence <= top.precedence) || + (token.isRightAssociative && token.precedence < top.precedence)) { + output.add(operatorStack.pop()) + } else break + } else if (top is Token.Function) { + output.add(operatorStack.pop()) + } else break + } + operatorStack.push(token) + } + is Token.LeftParen -> operatorStack.push(token) + is Token.RightParen -> { + while (operatorStack.isNotEmpty() && operatorStack.peek() !is Token.LeftParen) { + output.add(operatorStack.pop()) + } + if (operatorStack.isEmpty()) throw IllegalArgumentException("Unmatched right parenthesis") + operatorStack.pop() + if (operatorStack.isNotEmpty() && operatorStack.peek() is Token.Function) { + output.add(operatorStack.pop()) + } + } + } + } + + while (operatorStack.isNotEmpty()) { + val top = operatorStack.pop() + if (top is Token.LeftParen || top is Token.RightParen) { + throw IllegalArgumentException("Unmatched left parenthesis") + } + output.add(top) + } + return output + } + + private fun evaluateRpn(rpn: List, variables: Map): Double { + val stack = ArrayDeque() + + for (token in rpn) { + when (token) { + is Token.Number -> stack.push(token.value) + is Token.Variable -> { + // 변수 조회: 대소문자 무시 검색 지원 + val value = variables[token.name] + ?: variables[token.name.lowercase(Locale.ROOT)] + ?: variables.entries.find { it.key.equals(token.name, ignoreCase = true) }?.value + ?: 0.0 + stack.push(value) + } + is Token.Operator -> { + if (token.operandCount == 1) { + if (stack.isEmpty()) return Double.NaN + val value = stack.pop() + stack.push(if (token.symbol == '~') -value else value) + continue + } + if (stack.size < token.operandCount) return Double.NaN + val b = stack.pop() + val a = stack.pop() + val res = when (token.symbol) { + '+' -> a + b + '-' -> a - b + '*' -> a * b + '/' -> if (b != 0.0) a / b else Double.NaN + '%' -> if (b != 0.0) a % b else Double.NaN + '^' -> a.pow(b) + else -> Double.NaN + } + stack.push(res) + } + is Token.Function -> { + if (token.argCount == 1) { + if (stack.isEmpty()) return Double.NaN + val a = stack.pop() + val res = when (token.name) { + "sin" -> sin(a) + "cos" -> cos(a) + "tan" -> tan(a) + "asin" -> asin(a) + "acos" -> acos(a) + "atan" -> atan(a) + "sinh" -> sinh(a) + "cosh" -> cosh(a) + "tanh" -> tanh(a) + "sqrt" -> if (a >= 0) sqrt(a) else Double.NaN + "cbrt" -> cbrt(a) + "abs" -> abs(a) + "log" -> if (a > 0) log10(a) else Double.NaN + "ln" -> if (a > 0) ln(a) else Double.NaN + "log2" -> if (a > 0) log2(a) else Double.NaN + "exp" -> exp(a) + "round" -> kotlin.math.round(a) + "floor" -> floor(a) + "ceil" -> ceil(a) + "rad" -> Math.toRadians(a) + "deg" -> Math.toDegrees(a) + else -> Double.NaN + } + stack.push(res) + } else if (token.argCount == 2) { + if (stack.size < 2) return Double.NaN + val b = stack.pop() + val a = stack.pop() + val res = when (token.name) { + "pow" -> a.pow(b) + "min" -> min(a, b) + "max" -> max(a, b) + "atan2" -> atan2(a, b) + else -> Double.NaN + } + stack.push(res) + } + } + else -> {} + } + } + return if (stack.isNotEmpty()) stack.pop() else Double.NaN + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/formula/SMLinEvaluator.kt b/Android/app/src/main/java/com/dct/app/core/formula/SMLinEvaluator.kt new file mode 100644 index 0000000..203f6ac --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/formula/SMLinEvaluator.kt @@ -0,0 +1,109 @@ +package com.dct.app.core.formula + +import java.util.Locale + +/** + * Sensor Module LIN 구간 선형 보간 엔진 + * PC 레거시 CSMLINParser 호환: LIN("01-01"; x1; y1; x2; y2; ...; xn; yn) + */ +object SMLinEvaluator { + + data class Point(val x: Double, val y: Double) + + /** + * LIN 수식 표현식을 파싱하고 주어진 변수 맵(텔레메트리)을 기준으로 보간값을 계산합니다. + * @param expr LIN 수식 문자열 (예: "LIN(\"01-01\"; 0.5; 0.0; 2.5; 50.0; 4.5; 100.0)") + * @param variables 실시간 신호/센서 텔레메트리 맵 + * @return 보간 계산 결과 Double (오류 시 Double.NaN) + */ + fun evaluate(expr: String, variables: Map): Double { + val trimmed = expr.trim() + if (!trimmed.startsWith("LIN(", ignoreCase = true) || !trimmed.endsWith(")")) { + return Double.NaN + } + + val inner = trimmed.substring(4, trimmed.length - 1).trim() + if (!inner.contains(';')) return Double.NaN + val parts = inner.split(';').map { it.trim() } + + if (parts.size < 5 || (parts.size - 1) % 2 != 0) { + return Double.NaN + } + + // 1. 센서 채널 식별자 추출 (예: "01-01", "01-01 ch", "sm_01_01") + val rawCh = parts[0].replace("\"", "").replace("'", "").trim() + val chName = normalizeChannelName(rawCh) + + val cleanDigits = rawCh.replace(Regex("""[^0-9]"""), "") + val smUnderscore = if (cleanDigits.length >= 4) { + "sm_${cleanDigits.substring(0, 2)}_${cleanDigits.substring(2, 4)}" + } else "" + + val candidates = listOfNotNull( + rawCh, + chName, + if (smUnderscore.isNotEmpty()) smUnderscore else null, + rawCh.replace(" ", ""), + rawCh.replace("ch", "").trim() + ) + + // 2. 현재 센서 채널 값 조회 + val sensorValue = candidates.firstNotNullOfOrNull { variables[it] } + ?: variables.entries.find { entry -> candidates.any { c -> entry.key.equals(c, ignoreCase = true) } }?.value + ?: return Double.NaN + + if (sensorValue.isNaN()) return Double.NaN + + // 3. (x, y) 좌표점 쌍 추출 + val points = mutableListOf() + for (i in 1 until parts.size step 2) { + val x = parts[i].toDoubleOrNull() ?: return Double.NaN + val y = parts[i + 1].toDoubleOrNull() ?: return Double.NaN + points.add(Point(x, y)) + } + + if (points.size < 2) return Double.NaN + if (points.zipWithNext().any { (left, right) -> right.x <= left.x }) return Double.NaN + + // 4. PC CSMLINParser와 동일하게 입력된 순서의 구간을 사용한다. + val len = points.size + + if (sensorValue <= points.first().x) { + return points.first().y + } + if (sensorValue >= points.last().x) { + return points.last().y + } + + for (i in 0 until len - 1) { + val p1 = points[i] + val p2 = points[i + 1] + + if (sensorValue >= p1.x && sensorValue <= p2.x) { + val slope = (p2.y - p1.y) / (p2.x - p1.x) + return p1.y + slope * (sensorValue - p1.x) + } + } + + return Double.NaN + } + + /** + * 채널명을 표준 형식(예: "01-01 ch")으로 정규화합니다. + */ + fun normalizeChannelName(raw: String): String { + val clean = raw.trim() + if (clean.matches(Regex("""^\d{2}-\d{2}\s*ch$""", RegexOption.IGNORE_CASE))) { + return clean.lowercase(Locale.ROOT) + } + if (clean.matches(Regex("""^\d{2}-\d{2}$"""))) { + return "$clean ch" + } + if (clean.matches(Regex("""^sm_\d{2}_\d{2}$""", RegexOption.IGNORE_CASE))) { + val m = clean.substring(3, 5) + val c = clean.substring(6, 8) + return "$m-$c ch" + } + return clean + } +} diff --git a/Android/app/src/main/java/com/dct/app/core/theme/DctColors.kt b/Android/app/src/main/java/com/dct/app/core/theme/DctColors.kt new file mode 100644 index 0000000..2d3c11c --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/theme/DctColors.kt @@ -0,0 +1,92 @@ +package com.dct.app.core.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +// Stitch Precision Engineering Color Tokens +val StitchPrimaryBlue = Color(0xFF003D9B) +val StitchAccentBlue = Color(0xFF0052CC) +val StitchPrimaryContainer = Color(0xFFD9E2FF) +val StitchOnPrimary = Color(0xFFFFFFFF) + +val StitchSurfaceDark = Color(0xFF121418) +val StitchSurfaceContainer = Color(0xFF1A1C22) +val StitchSurfaceContainerHigh = Color(0xFF24262E) +val StitchSurfaceLight = Color(0xFFF8F9FA) + +val StitchTextPrimary = Color(0xFFE1E2E8) +val StitchTextSecondary = Color(0xFFC4C6D0) +val StitchTextTertiary = Color(0xFF8E9099) +val StitchBorderSubtle = Color(0xFF44474F) + +// Signal & Diagnostic Status Colors +val StatusGreen = Color(0xFF00C853) +val StatusYellow = Color(0xFFFFD600) +val StatusRed = Color(0xFFD50000) +val StatusCyan = Color(0xFF00E5FF) + +@Immutable +data class DctColorScheme( + val primary: Color = StitchAccentBlue, + val onPrimary: Color = StitchOnPrimary, + val primaryContainer: Color = StitchPrimaryContainer, + val surface: Color = StitchSurfaceDark, + val surfaceContainer: Color = StitchSurfaceContainer, + val surfaceContainerHigh: Color = StitchSurfaceContainerHigh, + val background: Color = StitchSurfaceDark, + val onBackground: Color = StitchTextPrimary, + val textPrimary: Color = StitchTextPrimary, + val textSecondary: Color = StitchTextSecondary, + val textTertiary: Color = StitchTextTertiary, + val borderSubtle: Color = StitchBorderSubtle, + val statusGreen: Color = StatusGreen, + val statusYellow: Color = StatusYellow, + val statusRed: Color = StatusRed, + val statusCyan: Color = StatusCyan +) + +val LocalDctColors = staticCompositionLocalOf { DctColorScheme() } + +object DctColors { + val PrimaryBlue = StitchPrimaryBlue + val AccentBlue = StitchAccentBlue + val SurfaceDark = StitchSurfaceDark + val SurfaceContainer = StitchSurfaceContainer + val TextPrimary = StitchTextPrimary + val TextSecondary = StitchTextSecondary + val StatusOk = StatusGreen + val StatusWarning = StatusYellow + val StatusError = StatusRed + val StatusInfo = StatusCyan +} + +/** + * Windows Win32 COLORREF (0x00BBGGRR) 색상 값을 Compose Color(sRGB: 0xFFAARRGGBB)로 변환합니다. + * - Red: (colorRef & 0xFF) + * - Green: ((colorRef >> 8) & 0xFF) + * - Blue: ((colorRef >> 16) & 0xFF) + */ +fun Long.toComposeColor(): Color { + val colorRef = this and 0xFFFFFFL + val r = (colorRef and 0xFFL).toInt() + val g = ((colorRef shr 8) and 0xFFL).toInt() + val b = ((colorRef shr 16) and 0xFFL).toInt() + return Color(r, g, b) +} + +fun Int.toComposeColor(): Color { + return this.toLong().toComposeColor() +} + +fun Long.toAndroidColorInt(): Int { + val colorRef = this and 0xFFFFFFL + val r = (colorRef and 0xFFL).toInt() + val g = ((colorRef shr 8) and 0xFFL).toInt() + val b = ((colorRef shr 16) and 0xFFL).toInt() + return android.graphics.Color.rgb(r, g, b) +} + +fun Int.toAndroidColorInt(): Int { + return this.toLong().toAndroidColorInt() +} diff --git a/Android/app/src/main/java/com/dct/app/core/theme/DctTheme.kt b/Android/app/src/main/java/com/dct/app/core/theme/DctTheme.kt new file mode 100644 index 0000000..f881f26 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/theme/DctTheme.kt @@ -0,0 +1,58 @@ +package com.dct.app.core.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + +// Stitch Light Theme - 라이트 테마용 텍스트 색 (Stitch 명세: #191C1E) +private val StitchOnSurfaceLight = Color(0xFF191C1E) +private val StitchOnSurfaceVariantLight = Color(0xFF434654) +private val StitchOutlineVariantLight = Color(0xFFC3C6D6) + +private val LightColorScheme = lightColorScheme( + primary = StitchAccentBlue, + onPrimary = StitchOnPrimary, + primaryContainer = StitchPrimaryContainer, + onPrimaryContainer = StitchOnSurfaceLight, + surface = StitchSurfaceLight, + onSurface = StitchOnSurfaceLight, + onSurfaceVariant = StitchOnSurfaceVariantLight, + background = StitchSurfaceLight, + onBackground = StitchOnSurfaceLight, + outline = StitchOutlineVariantLight, + outlineVariant = StitchOutlineVariantLight +) + +@Composable +fun DctTheme( + content: @Composable () -> Unit +) { + val dctColors = DctColorScheme() + val dctTypography = DctTypographyTokens() + + CompositionLocalProvider( + LocalDctColors provides dctColors, + LocalDctTypography provides dctTypography + ) { + MaterialTheme( + colorScheme = LightColorScheme, + typography = MaterialTypography, + content = content + ) + } +} + +object DctThemeTokens { + val colors: DctColorScheme + @Composable + @ReadOnlyComposable + get() = LocalDctColors.current + + val typography: DctTypographyTokens + @Composable + @ReadOnlyComposable + get() = LocalDctTypography.current +} diff --git a/Android/app/src/main/java/com/dct/app/core/theme/DctTypography.kt b/Android/app/src/main/java/com/dct/app/core/theme/DctTypography.kt new file mode 100644 index 0000000..a30d61e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/theme/DctTypography.kt @@ -0,0 +1,121 @@ +package com.dct.app.core.theme + +import androidx.compose.material3.Typography +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Stitch Font Families +val ManropeFontFamily = FontFamily.SansSerif +val JetBrainsMonoFontFamily = FontFamily.Monospace + +@Immutable +data class DctTypographyTokens( + val headlineLarge: TextStyle = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 32.sp + ), + val headlineMedium: TextStyle = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp + ), + val titleMedium: TextStyle = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp + ), + val bodyMedium: TextStyle = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp + ), + val numericLarge: TextStyle = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 34.sp + ), + val numericMedium: TextStyle = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 20.sp + ), + val numericSmall: TextStyle = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 11.sp, + lineHeight = 14.sp + ), + val labelSmall: TextStyle = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + lineHeight = 12.sp + ) +) + +val LocalDctTypography = staticCompositionLocalOf { DctTypographyTokens() } + +val MaterialTypography = Typography( + headlineLarge = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 32.sp + ), + headlineMedium = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp + ), + titleMedium = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp + ), + bodyMedium = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp + ), + labelSmall = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + lineHeight = 12.sp + ) +) + +object DctTypography { + val HeadlineLarge = TextStyle( + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 32.sp + ) + val NumericLarge = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 34.sp + ) + val NumericMedium = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 20.sp + ) +} diff --git a/Android/app/src/main/java/com/dct/app/core/util/TextStreamDecoder.kt b/Android/app/src/main/java/com/dct/app/core/util/TextStreamDecoder.kt new file mode 100644 index 0000000..273f148 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/core/util/TextStreamDecoder.kt @@ -0,0 +1,129 @@ +package com.dct.app.core.util + +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.charset.Charset +import java.nio.charset.CodingErrorAction + +/** + * 인코딩(UTF-8, CP949/EUC-KR, Windows-1252/ISO-8859-1)을 지능적으로 자동 감지 및 변환하는 텍스트 스트림 디코더 + * 한국 및 글로벌 자동차 제어기 DBC / A2L / DPC 파일 내 특수문자(℃, °C, Ω 등) 깨짐 방지 + */ +object TextStreamDecoder { + + /** + * 입력 스트림의 바이트 배열을 지능적으로 분석하여 올바른 문자열로 디코딩 + */ + fun readStringSmart(inputStream: InputStream): String { + val bytes = inputStream.readBytes() + return decodeBytesSmart(bytes) + } + + /** + * 입력 스트림으로부터 라인 단위 문자열 목록 반환 + */ + fun readLinesSmart(inputStream: InputStream): List { + val text = readStringSmart(inputStream) + return text.lines() + } + + /** + * 바이트 배열의 인코딩(BOM, UTF-8, CP949/EUC-KR, Windows-1252)을 판별하여 디코딩 + */ + fun decodeBytesSmart(bytes: ByteArray): String { + if (bytes.isEmpty()) return "" + + // 1. UTF-8 BOM (EF BB BF) + if (bytes.size >= 3 && bytes[0] == 0xEF.toByte() && bytes[1] == 0xBB.toByte() && bytes[2] == 0xBF.toByte()) { + return String(bytes, 3, bytes.size - 3, Charsets.UTF_8) + } + // 2. UTF-16 LE BOM (FF FE) + if (bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xFE.toByte()) { + return String(bytes, 2, bytes.size - 2, Charsets.UTF_16LE) + } + // 3. UTF-16 BE BOM (FE FF) + if (bytes.size >= 2 && bytes[0] == 0xFE.toByte() && bytes[1] == 0xFF.toByte()) { + return String(bytes, 2, bytes.size - 2, Charsets.UTF_16BE) + } + + // 4. 엄격한 UTF-8 디코딩 시도 (malformed input 발생 시 즉시 감지) + try { + val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + val buffer = ByteBuffer.wrap(bytes) + val charBuffer = decoder.decode(buffer) + val utf8Str = charBuffer.toString() + if (!utf8Str.contains('\uFFFD')) { + return sanitizeString(utf8Str) + } + } catch (_: Exception) {} + + // 5. non-ASCII 바이트 분석 (EUC-KR 2바이트 페어 vs Windows-1252 싱글바이트 판별) + var isEucKr = true + var i = 0 + var nonAsciiCount = 0 + while (i < bytes.size) { + val b = bytes[i].toInt() and 0xFF + if (b < 0x80) { + i++ + } else { + nonAsciiCount++ + if (b in 0xA1..0xFE && i + 1 < bytes.size) { + val nextB = bytes[i + 1].toInt() and 0xFF + if (nextB in 0xA1..0xFE) { + // 유효한 EUC-KR 2바이트 쌍 (완성형 한글 및 특수기호 ℃ 등) + i += 2 + continue + } + } + isEucKr = false + break + } + } + + if (nonAsciiCount > 0 && isEucKr) { + try { + val cs = Charset.forName("EUC-KR") + val krStr = String(bytes, cs) + if (!krStr.contains('\uFFFD')) { + return sanitizeString(krStr) + } + } catch (_: Exception) {} + } + + // 6. 유럽/글로벌 자동차 DBC/A2L 표준 인코딩 (Windows-1252: 0xB0 = ° 등) + try { + if (Charset.isSupported("windows-1252")) { + val cs = Charset.forName("windows-1252") + val winStr = String(bytes, cs) + return sanitizeString(winStr) + } + } catch (_: Exception) {} + + return sanitizeString(String(bytes, Charsets.ISO_8859_1)) + } + + /** + * 자동차 DBC / A2L 단위(Unit) 및 설명(Description) 내 깨진 문자열 복원 및 정제 + */ + fun sanitizeUnit(unit: String): String { + var result = unit.trim().trim('"') + if (result.contains('\uFFFD')) { + result = result.replace("\uFFFD\uFFFD", "℃") + .replace("\uFFFD", "°") + } + if (result == "'C" || result == "deg C" || result == "degC" || result == "DegC") { + result = "℃" + } else if (result == "deg" || result == "Deg") { + result = "°" + } + return result + } + + private fun sanitizeString(str: String): String { + if (!str.contains('\uFFFD')) return str + return str.replace("\uFFFD\uFFFD", "℃") + .replace("\uFFFD", "°") + } +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/dct/app/data/communication/BluetoothCommunicationManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/BluetoothCommunicationManager.kt new file mode 100644 index 0000000..071e839 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/communication/BluetoothCommunicationManager.kt @@ -0,0 +1,268 @@ +package com.dct.app.data.communication + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothSocket +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.IOException +import java.util.UUID + +/** + * Android 표준 Bluetooth 실제 통신 관리자 (SPP / RFCOMM 및 BLE) + */ +class BluetoothCommunicationManager( + private val context: Context +) { + private val bluetoothManager: BluetoothManager? = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager?.adapter + + private val _isBluetoothEnabled = MutableStateFlow(bluetoothAdapter?.isEnabled == true) + val isBluetoothEnabled: StateFlow = _isBluetoothEnabled.asStateFlow() + + private val _isSearching = MutableStateFlow(false) + val isSearching: StateFlow = _isSearching.asStateFlow() + + private val _bondedDevices = MutableStateFlow>(emptyList()) + val bondedDevices: StateFlow> = _bondedDevices.asStateFlow() + + private val _scannedDevices = MutableStateFlow>(emptyList()) + val scannedDevices: StateFlow> = _scannedDevices.asStateFlow() + + private val _connectedDevice = MutableStateFlow(null) + val connectedDevice: StateFlow = _connectedDevice.asStateFlow() + + private var activeSocket: BluetoothSocket? = null + private var readJob: Job? = null + private var receiver: BroadcastReceiver? = null + private val scope = CoroutineScope(Dispatchers.IO + Job()) + + private val parser = com.dct.app.data.protocol.SlidingRingBufferParser() + private val _packetStream = kotlinx.coroutines.flow.MutableSharedFlow( + replay = 0, + extraBufferCapacity = 256, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + ) + val packetStream: kotlinx.coroutines.flow.SharedFlow = _packetStream + + companion object { + // 표준 Serial Port Profile (SPP) UUID - CAN/OBD/ECU 통신 동글 표준 + val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") + } + + /** + * 페어링된 기기 목록 갱신 + */ + @SuppressLint("MissingPermission") + fun refreshBondedDevices() { + _isBluetoothEnabled.value = bluetoothAdapter?.isEnabled == true + if (bluetoothAdapter != null && bluetoothAdapter.isEnabled) { + try { + _bondedDevices.value = bluetoothAdapter.bondedDevices?.toList() ?: emptyList() + } catch (_: SecurityException) { + _bondedDevices.value = emptyList() + } + } else { + _bondedDevices.value = emptyList() + } + } + + /** + * 주변 Bluetooth 기기 실시간 탐색 시작 + */ + @SuppressLint("MissingPermission") + fun startDiscovery() { + if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) return + + registerReceiver() + _scannedDevices.value = emptyList() + _isSearching.value = true + + try { + if (bluetoothAdapter.isDiscovering) { + bluetoothAdapter.cancelDiscovery() + } + bluetoothAdapter.startDiscovery() + } catch (_: SecurityException) { + _isSearching.value = false + } + } + + /** + * 기기 탐색 중지 + */ + @SuppressLint("MissingPermission") + fun stopDiscovery() { + try { + if (bluetoothAdapter?.isDiscovering == true) { + bluetoothAdapter.cancelDiscovery() + } + } catch (_: SecurityException) {} + _isSearching.value = false + } + + /** + * BroadcastReceiver 등록 + */ + fun registerReceiver() { + if (receiver != null) return + + val filter = IntentFilter().apply { + addAction(BluetoothDevice.ACTION_FOUND) + addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED) + addAction(BluetoothAdapter.ACTION_STATE_CHANGED) + } + + receiver = object : BroadcastReceiver() { + @SuppressLint("MissingPermission") + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + BluetoothDevice.ACTION_FOUND -> { + val device = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } + device?.let { newDev -> + val current = _scannedDevices.value.toMutableList() + if (current.none { it.address == newDev.address }) { + current.add(newDev) + _scannedDevices.value = current + } + } + } + BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> { + _isSearching.value = false + } + BluetoothAdapter.ACTION_STATE_CHANGED -> { + _isBluetoothEnabled.value = bluetoothAdapter?.isEnabled == true + refreshBondedDevices() + } + } + } + } + + try { + context.registerReceiver(receiver, filter) + } catch (_: Exception) {} + } + + /** + * BroadcastReceiver 해제 + */ + fun unregisterReceiver() { + receiver?.let { + try { + context.unregisterReceiver(it) + } catch (_: Exception) {} + receiver = null + } + } + + /** + * Bluetooth 기기 실제 연결 (SPP 소켓 생성 및 수신 스트림 활성화) + */ + @SuppressLint("MissingPermission") + fun connect( + device: BluetoothDevice, + onSuccess: () -> Unit = {}, + onFailure: (String) -> Unit = {} + ) { + scope.launch { + try { + stopDiscovery() + readJob?.cancel() + activeSocket?.close() + + val socket = device.createRfcommSocketToServiceRecord(SPP_UUID) + socket.connect() + activeSocket = socket + _connectedDevice.value = device + parser.reset() + + // 실제 하드웨어 바이트 스트림 수신 루프 시작 + readJob = launch { + val buffer = ByteArray(4096) + val inputStream = socket.inputStream + try { + while (activeSocket?.isConnected == true) { + val bytesRead = inputStream.read(buffer) + if (bytesRead > 0) { + val packets = parser.parseStream(buffer, bytesRead) + for (packet in packets) { + _packetStream.emit(packet) + } + } else if (bytesRead < 0) { + break + } + } + } catch (_: IOException) { + // 소켓 연결 종료 + } finally { + _connectedDevice.value = null + } + } + + onSuccess() + } catch (e: IOException) { + activeSocket = null + _connectedDevice.value = null + onFailure(e.message ?: "연결 실패") + } catch (e: SecurityException) { + activeSocket = null + _connectedDevice.value = null + onFailure("Bluetooth 권한 오류: ${e.message}") + } + } + } + + private val codec = com.dct.app.data.protocol.DctPacketCodec() + + /** + * 실제 하드웨어로 바이너리 패킷 송신 + */ + suspend fun sendCommand(packet: com.dct.app.domain.model.protocol.DctPacket): Boolean { + val socket = activeSocket ?: return false + if (!socket.isConnected) return false + + return kotlinx.coroutines.withContext(Dispatchers.IO) { + try { + val encodedBytes = codec.encode(packet) + val outputStream = socket.outputStream + outputStream.write(encodedBytes) + outputStream.flush() + true + } catch (_: Exception) { + false + } + } + } + + /** + * Bluetooth 연결 해제 + */ + fun disconnect(onComplete: () -> Unit = {}) { + scope.launch { + try { + readJob?.cancel() + readJob = null + activeSocket?.close() + } catch (_: Exception) {} + activeSocket = null + _connectedDevice.value = null + onComplete() + } + } +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt new file mode 100644 index 0000000..b4bcc69 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt @@ -0,0 +1,27 @@ +package com.dct.app.data.communication + +import android.content.SharedPreferences + +data class WifiConnectionInput(val ip: String = "", val port: String = "") { + val portNumber: Int? get() = port.trim().takeIf { it.isNotEmpty() && it.all { ch -> ch in '0'..'9' } } + ?.toIntOrNull()?.takeIf { it in 1..65535 } + val isIpValid: Boolean get() { + val parts = ip.trim().split('.') + return parts.size == 4 && parts.all { part -> + part.isNotEmpty() && part.length <= 3 && part.all { it in '0'..'9' } && part.toInt() in 0..255 + } + } + val canConnect: Boolean get() = isIpValid && portNumber != null +} + +/** App-local manual connection draft, independent of project and connection success. */ +class WifiConnectionPreferences(private val preferences: SharedPreferences) { + fun load() = WifiConnectionInput(preferences.getString("manual_ip", "").orEmpty(), + preferences.getString("manual_port", "").orEmpty()) + + fun save(input: WifiConnectionInput) { + preferences.edit().putString("manual_ip", input.ip).putString("manual_port", input.port).apply() + } + + companion object { const val FILE_NAME = "wifi_connection_input" } +} diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectManager.kt new file mode 100644 index 0000000..f06e50c --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiDirectManager.kt @@ -0,0 +1,434 @@ +package com.dct.app.data.communication + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.p2p.WifiP2pConfig +import android.net.wifi.p2p.WifiP2pDevice +import android.net.wifi.p2p.WifiP2pDeviceList +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pInfo +import android.net.wifi.p2p.WifiP2pManager +import android.os.Build +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetSocketAddress + +data class DiscoveredLanServer( + val deviceName: String, + val hostIp: String, + val port: Int = 6000, + val lastSeen: Long = System.currentTimeMillis() +) + +/** + * Android 표준 Wi-Fi Direct 실제 통신 관리자 및 LAN UDP 자동 디스커버리 + */ +class WifiDirectManager( + private val context: Context +) { + private val wifiP2pManager: WifiP2pManager? = context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager + private var channel: WifiP2pManager.Channel? = null + private var receiver: BroadcastReceiver? = null + + private val _isP2pEnabled = MutableStateFlow(false) + val isP2pEnabled: StateFlow = _isP2pEnabled.asStateFlow() + + private val _isSearching = MutableStateFlow(false) + val isSearching: StateFlow = _isSearching.asStateFlow() + + private val _peers = MutableStateFlow>(emptyList()) + val peers: StateFlow> = _peers.asStateFlow() + + private val _lanServers = MutableStateFlow>(emptyList()) + val lanServers: StateFlow> = _lanServers.asStateFlow() + + private val _connectionInfo = MutableStateFlow(null) + val connectionInfo: StateFlow = _connectionInfo.asStateFlow() + + private val _groupInfo = MutableStateFlow(null) + val groupInfo: StateFlow = _groupInfo.asStateFlow() + + private val _thisDevice = MutableStateFlow(null) + val thisDevice: StateFlow = _thisDevice.asStateFlow() + + private val _connectedDeviceName = MutableStateFlow(null) + val connectedDeviceName: StateFlow = _connectedDeviceName.asStateFlow() + + private val _connectedHostIp = MutableStateFlow(null) + val connectedHostIp: StateFlow = _connectedHostIp.asStateFlow() + + private val _lastErrorReason = MutableStateFlow(null) + val lastErrorReason: StateFlow = _lastErrorReason.asStateFlow() + + private var pendingIpCallback: ((String) -> Unit)? = null + private var udpDiscoveryJob: Job? = null + private val scope = CoroutineScope(Dispatchers.IO) + + init { + wifiP2pManager?.let { manager -> + channel = manager.initialize(context, Looper.getMainLooper()) { + // Channel lost + } + } + } + + /** + * BroadcastReceiver 등록 및 UDP LAN 디스커버리 시작 + */ + fun registerReceiver() { + if (receiver != null) return + + val intentFilter = IntentFilter().apply { + addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION) + addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION) + addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION) + addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION) + } + + receiver = object : BroadcastReceiver() { + @SuppressLint("MissingPermission") + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> { + val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) + _isP2pEnabled.value = (state == WifiP2pManager.WIFI_P2P_STATE_ENABLED) + } + WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> { + channel?.let { ch -> + wifiP2pManager?.requestPeers(ch) { peerList: WifiP2pDeviceList? -> + val deviceList = peerList?.deviceList?.toList() ?: emptyList() + _peers.value = deviceList + } + } + } + WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> { + refreshConnectionAndPeers() + } + WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> { + val thisDev = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE, WifiP2pDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE) + } + _thisDevice.value = thisDev + } + } + } + } + + try { + context.registerReceiver(receiver, intentFilter) + refreshConnectionAndPeers() + } catch (_: Exception) {} + + startUdpDiscovery() + } + + private var multicastLock: android.net.wifi.WifiManager.MulticastLock? = null + + private fun startUdpDiscovery() { + if (udpDiscoveryJob != null) return + try { + val wm = context.applicationContext.getSystemService(android.content.Context.WIFI_SERVICE) as? android.net.wifi.WifiManager + multicastLock = wm?.createMulticastLock("DctUdpDiscoveryLock")?.apply { + setReferenceCounted(true) + acquire() + } + } catch (_: Throwable) {} + + udpDiscoveryJob = scope.launch { + var socket: DatagramSocket? = null + try { + socket = DatagramSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(6005)) + soTimeout = 3000 + } + val buffer = ByteArray(1024) + val packet = DatagramPacket(buffer, buffer.size) + + while (isActive) { + try { + socket.receive(packet) + val text = String(packet.data, 0, packet.length, Charsets.UTF_8).trim() + if (text.startsWith("DCT_BEACON|")) { + val parts = text.split("|") + if (parts.size >= 4) { + val srvName = parts[1] + val srvIp = parts[2] + val srvPort = parts[3].toIntOrNull() ?: 6000 + + val server = DiscoveredLanServer( + deviceName = srvName, + hostIp = srvIp, + port = srvPort, + lastSeen = System.currentTimeMillis() + ) + + val current = _lanServers.value.filter { + it.hostIp != srvIp && (System.currentTimeMillis() - it.lastSeen < 10000) + } + _lanServers.value = listOf(server) + current + } + } + } catch (_: java.net.SocketTimeoutException) { + // Clean up stale servers (> 10s) + _lanServers.value = _lanServers.value.filter { + System.currentTimeMillis() - it.lastSeen < 10000 + } + } catch (_: Exception) {} + } + } catch (_: Exception) { + } finally { + socket?.close() + } + } + } + + private fun stopUdpDiscovery() { + udpDiscoveryJob?.cancel() + udpDiscoveryJob = null + try { + if (multicastLock?.isHeld == true) { + multicastLock?.release() + } + multicastLock = null + } catch (_: Throwable) {} + } + + /** + * 연결 상태, 그룹 정보, 피어 목록을 일괄 요청하여 동기화 + */ + @SuppressLint("MissingPermission") + fun refreshConnectionAndPeers() { + val ch = channel ?: return + wifiP2pManager?.requestConnectionInfo(ch) { info: WifiP2pInfo? -> + _connectionInfo.value = info + if (info?.groupFormed == true) { + val ownerIp = info.groupOwnerAddress?.hostAddress + _connectedHostIp.value = ownerIp + if (ownerIp != null) { + pendingIpCallback?.invoke(ownerIp) + pendingIpCallback = null + } + } else { + _connectedDeviceName.value = null + _connectedHostIp.value = null + _groupInfo.value = null + pendingIpCallback = null + } + } + + wifiP2pManager?.requestGroupInfo(ch) { group: WifiP2pGroup? -> + _groupInfo.value = group + if (group != null) { + if (_connectedDeviceName.value == null) { + val isOwner = _connectionInfo.value?.isGroupOwner == true + if (isOwner) { + val firstClient = group.clientList.firstOrNull() + if (firstClient != null) { + _connectedDeviceName.value = firstClient.deviceName.ifBlank { firstClient.deviceAddress } + } + } else { + val owner = group.owner + if (owner != null) { + _connectedDeviceName.value = owner.deviceName.ifBlank { owner.deviceAddress } + } + } + } + } + } + + wifiP2pManager?.requestPeers(ch) { peerList: WifiP2pDeviceList? -> + val deviceList = peerList?.deviceList?.toList() ?: emptyList() + _peers.value = deviceList + } + } + + /** + * 앱 실행 시 이미 Wi-Fi Direct 그룹이 연결되어 있는지 확인하고 콜백 반환 (isGroupOwner 포함) + */ + @SuppressLint("MissingPermission") + fun checkExistingConnection( + onConnected: (deviceName: String, hostIp: String, isGroupOwner: Boolean) -> Unit + ) { + val ch = channel ?: return + wifiP2pManager?.requestConnectionInfo(ch) { info: WifiP2pInfo? -> + if (info?.groupFormed == true) { + _connectionInfo.value = info + val hostIp = info.groupOwnerAddress?.hostAddress ?: "192.168.49.1" + _connectedHostIp.value = hostIp + val isOwner = info.isGroupOwner + + wifiP2pManager.requestGroupInfo(ch) { group: WifiP2pGroup? -> + _groupInfo.value = group + val devName = if (isOwner) { + group?.clientList?.firstOrNull()?.let { it.deviceName.ifBlank { it.deviceAddress } } ?: "Wi-Fi Direct Client" + } else { + group?.owner?.let { it.deviceName.ifBlank { it.deviceAddress } } ?: "Wi-Fi Direct Owner" + } + _connectedDeviceName.value = devName + onConnected(devName, hostIp, isOwner) + } + } + } + } + + @SuppressLint("MissingPermission") + fun checkExistingConnection( + onConnected: (deviceName: String, hostIp: String) -> Unit + ) { + checkExistingConnection { dev, ip, _ -> onConnected(dev, ip) } + } + + /** + * BroadcastReceiver 해제 + */ + fun unregisterReceiver() { + stopUdpDiscovery() + receiver?.let { + try { + context.unregisterReceiver(it) + } catch (_: Exception) {} + receiver = null + } + } + + /** + * 주변 Wi-Fi Direct 피어 실제 탐색 시작 (일시적 BUSY 시 1회 자동 재시도) + */ + @SuppressLint("MissingPermission") + fun discoverPeers( + isRetry: Boolean = false, + onSuccess: () -> Unit = {}, + onFailure: (Int) -> Unit = {} + ) { + val ch = channel ?: return + _isSearching.value = true + _lastErrorReason.value = null + wifiP2pManager?.discoverPeers(ch, object : WifiP2pManager.ActionListener { + override fun onSuccess() { + _isSearching.value = true + _lastErrorReason.value = null + onSuccess() + } + + override fun onFailure(reasonCode: Int) { + // 채널 일시적 혼잡(BUSY: 2)인 경우 800ms 후 1회 자동 재시도 + if (reasonCode == WifiP2pManager.BUSY && !isRetry) { + scope.launch { + kotlinx.coroutines.delay(800) + discoverPeers(isRetry = true, onSuccess = onSuccess, onFailure = onFailure) + } + return + } + _isSearching.value = false + _lastErrorReason.value = reasonCode + onFailure(reasonCode) + } + }) + } + + /** + * 피어 탐색 정지 + */ + fun stopPeerDiscovery() { + val ch = channel ?: return + wifiP2pManager?.stopPeerDiscovery(ch, object : WifiP2pManager.ActionListener { + override fun onSuccess() { + _isSearching.value = false + } + override fun onFailure(reason: Int) { + _isSearching.value = false + } + }) + } + + /** + * 선택한 Wi-Fi Direct 기기에 실제 연결 요청 (그룹 형성 후 IP 콜백 지원) + */ + @SuppressLint("MissingPermission") + fun connect( + device: WifiP2pDevice, + onSuccessWithIp: (String) -> Unit = {}, + onFailure: (Int) -> Unit = {} + ) { + val ch = channel ?: return + val config = WifiP2pConfig().apply { + deviceAddress = device.deviceAddress + // 태블릿이 Client가 되고 단말이 Group Owner(GO)가 되도록 우선순위를 0으로 양보 + groupOwnerIntent = 0 + } + + pendingIpCallback = onSuccessWithIp + + wifiP2pManager?.connect(ch, config, object : WifiP2pManager.ActionListener { + override fun onSuccess() { + _connectedDeviceName.value = device.deviceName.ifBlank { device.deviceAddress } + } + + override fun onFailure(reasonCode: Int) { + _connectedDeviceName.value = null + _connectedHostIp.value = null + pendingIpCallback = null + onFailure(reasonCode) + } + }) + } + + /** + * Wi-Fi Direct 연결 해제 (P2P 그룹 제거 및 진행 중인 연결 취소) + */ + fun disconnect(onComplete: () -> Unit = {}) { + val ch = channel + if (ch == null) { + _connectedDeviceName.value = null + _connectionInfo.value = null + _groupInfo.value = null + _connectedHostIp.value = null + pendingIpCallback = null + onComplete() + return + } + + // 1. 진행 중인 연결 요청 취소 + wifiP2pManager?.cancelConnect(ch, object : WifiP2pManager.ActionListener { + override fun onSuccess() {} + override fun onFailure(reasonCode: Int) {} + }) + + // 2. 형성된 P2P 그룹 제거 (실제 OS 레벨 Wi-Fi Direct 링크 해제) + wifiP2pManager?.removeGroup(ch, object : WifiP2pManager.ActionListener { + override fun onSuccess() { + _connectedDeviceName.value = null + _connectionInfo.value = null + _groupInfo.value = null + _connectedHostIp.value = null + pendingIpCallback = null + onComplete() + } + + override fun onFailure(reasonCode: Int) { + _connectedDeviceName.value = null + _connectionInfo.value = null + _groupInfo.value = null + _connectedHostIp.value = null + pendingIpCallback = null + onComplete() + } + }) + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt new file mode 100644 index 0000000..2d1af51 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt @@ -0,0 +1,327 @@ +package com.dct.app.data.communication + +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.data.protocol.SlidingRingBufferParser +import com.dct.app.domain.model.protocol.DctPacket +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.IOException +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket + +/** + * Wi-Fi TCP/IP 하이브리드(서버/클라이언트) 실제 하드웨어 통신 소켓 관리자 (기본 포트: 6000) + * + * Wi-Fi Direct 환경에서 태블릿이 Group Owner(서버)가 되든 Client(클라이언트)가 되든 + * 상관없이 안정적인 양방향 송수신 세션을 지원합니다. + */ +class WifiSocketManager { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var activeSocket: Socket? = null + private var serverSocket: ServerSocket? = null + private var serverJob: Job? = null + private var readJob: Job? = null + private val connectionGeneration = java.util.concurrent.atomic.AtomicLong() + + @Synchronized + private fun nextConnectionGeneration(): Long = connectionGeneration.incrementAndGet() + + private val parser = SlidingRingBufferParser() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _isServerRunning = MutableStateFlow(false) + val isServerRunning: StateFlow = _isServerRunning.asStateFlow() + + private val _connectedHost = MutableStateFlow(null) + val connectedHost: StateFlow = _connectedHost.asStateFlow() + + private val _packetStream = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 256, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val packetStream: SharedFlow = _packetStream + + companion object { + const val DEFAULT_PORT = 6000 + const val CONNECT_TIMEOUT_MS = 5000 + } + + /** + * 하이브리드 듀얼 모드 시작 (태블릿의 Group Owner 여부에 따라 서버/클라이언트 자동 기동) + */ + fun startDualMode( + isGroupOwner: Boolean, + hostIp: String = "192.168.49.1", + port: Int = DEFAULT_PORT, + onConnected: (isServer: Boolean, remoteAddress: String) -> Unit = { _, _ -> }, + onWaiting: (String) -> Unit = {}, + onFailure: (String) -> Unit = {} + ) { + val generation = nextConnectionGeneration() + scope.launch { + if (isGroupOwner) { + // 1. 태블릿이 Group Owner(GO)인 경우: ServerSocket(Port 6000)을 열고 상대방 접속 대기 + val listening = startServerInternal(port, generation, onClientConnected = { clientSocket -> + val remoteIp = clientSocket.inetAddress?.hostAddress ?: "Client" + onConnected(true, remoteIp) + }, onFailure = onFailure) + if (listening) onWaiting("TCP 서버가 포트 $port 에서 장비 접속을 기다리는 중입니다.") + } else { + // 2. 태블릿이 Client인 경우: 상대 Group Owner IP로 ClientSocket 접속 시도 + // (만약 접속 실패 시 상대방이 태블릿으로 접속할 수 있도록 서버 리스너도 안전망으로 가동) + try { + connectClientInternal(hostIp, port, generation) + onConnected(false, hostIp) + } catch (e: Exception) { + if (e is CancellationException || generation != connectionGeneration.get()) return@launch + // 상대방이 서버를 아직 안 띄웠거나 태블릿으로 접속하려는 경우를 대비해 ServerSocket도 가동 + val listening = startServerInternal(port, generation, onClientConnected = { clientSocket -> + val remoteIp = clientSocket.inetAddress?.hostAddress ?: "Client" + onConnected(true, remoteIp) + }, onFailure = onFailure) + if (listening) { + onWaiting("${e.message ?: "클라이언트 접속 실패"}; TCP 서버 접속 대기 중") + } + } + } + } + } + + /** + * TCP 서버 소켓 시작 (Port 6000 수신 대기 및 다중/단일 클라이언트 접속 처리) + */ + fun startServer( + port: Int = DEFAULT_PORT, + onClientConnected: (Socket) -> Unit = {} + ) { + val generation = nextConnectionGeneration() + scope.launch { + startServerInternal(port, generation, onClientConnected) + } + } + + @Synchronized + private fun startServerInternal( + port: Int, + generation: Long, + onClientConnected: (Socket) -> Unit = {}, + onFailure: (String) -> Unit = {} + ): Boolean { + if (generation != connectionGeneration.get()) return false + try { + if (serverSocket != null && serverSocket?.isClosed == false && _isServerRunning.value) { + serverSocket?.close() // Replace listener callbacks for the new connection attempt. + } + + serverSocket?.close() + serverJob?.cancel() + + val sSocket = ServerSocket(port) + serverSocket = sSocket + _isServerRunning.value = true + + serverJob = scope.launch { + try { + while (isActive && generation == connectionGeneration.get() && !sSocket.isClosed) { + val clientSocket = sSocket.accept() + clientSocket.tcpNoDelay = true + clientSocket.soTimeout = 0 + + val remoteAddr = clientSocket.inetAddress?.hostAddress ?: "Remote Client" + if (attachSocket(clientSocket, remoteAddr, generation)) onClientConnected(clientSocket) + } + } catch (_: IOException) { + // ServerSocket close + } finally { + synchronized(this@WifiSocketManager) { + if (serverSocket === sSocket) _isServerRunning.value = false + } + } + } + return true + } catch (e: Exception) { + _isServerRunning.value = false + onFailure(e.message ?: "TCP 서버 시작 실패") + return false + } + } + + /** + * Wi-Fi TCP 클라이언트 소켓 연결 + */ + fun connect( + host: String, + port: Int = DEFAULT_PORT, + onSuccess: () -> Unit = {}, + onFailure: (String) -> Unit = {} + ) { + val generation = nextConnectionGeneration() + scope.launch { + try { + connectClientInternal(host, port, generation) + onSuccess() + } catch (e: Exception) { + if (e is CancellationException || generation != connectionGeneration.get()) return@launch + disconnectInternal() + onFailure(e.message ?: "Wi-Fi 소켓 연결 실패") + } + } + } + + private fun connectClientInternal(host: String, port: Int, generation: Long) { + synchronized(this) { + if (generation != connectionGeneration.get()) throw CancellationException("Superseded connection") + disconnectActiveSocketOnly() + } + val socket = Socket() + try { + socket.tcpNoDelay = true + socket.soTimeout = 0 + socket.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS) + if (!attachSocket(socket, host, generation)) throw CancellationException("Superseded connection") + } catch (e: Exception) { + socket.close() + throw e + } + } + + /** + * 연결된 소켓을 활성 통신 소켓으로 등록하고 수신 루프 가동 + */ + @Synchronized + private fun attachSocket(socket: Socket, hostName: String, generation: Long): Boolean { + if (generation != connectionGeneration.get()) { + socket.close() + return false + } + disconnectActiveSocketOnly() + + activeSocket = socket + _isConnected.value = true + _connectedHost.value = hostName + parser.reset() + + // 실제 하드웨어/원격 기기 바이트 스트림 수신 루프 + readJob = scope.launch { + val buffer = ByteArray(4096) + try { + val inputStream = socket.getInputStream() + while (isActive && activeSocket === socket && !socket.isClosed) { + val bytesRead = inputStream.read(buffer) + if (bytesRead > 0) { + val packets = synchronized(this@WifiSocketManager) { + if (activeSocket !== socket || generation != connectionGeneration.get()) emptyList() + else parser.parseStream(buffer, bytesRead) + } + for (packet in packets) { + _packetStream.emit(packet) + } + } else if (bytesRead < 0) { + break + } + } + } catch (_: IOException) { + // 소켓 연결 종료 + } finally { + synchronized(this@WifiSocketManager) { + if (activeSocket === socket) { + try { socket.close() } catch (_: Exception) {} + _isConnected.value = false + _connectedHost.value = null + activeSocket = null + } + } + } + } + return true + } + + private val codec = DctPacketCodec() + + /** + * 실제 하드웨어/원격 기기로 바이너리 패킷 송신 + */ + suspend fun sendCommand(packet: DctPacket): Boolean { + val socket = activeSocket ?: return false + if (!socket.isConnected || socket.isClosed) return false + + return withContext(Dispatchers.IO) { + try { + val encodedBytes = codec.encode(packet) + val outputStream = socket.getOutputStream() + outputStream.write(encodedBytes) + outputStream.flush() + true + } catch (_: Exception) { + synchronized(this@WifiSocketManager) { + if (activeSocket === socket) disconnectActiveSocketOnly() + } + false + } + } + } + + /** + * 소켓 및 서버 리스너 완전 해제 + */ + fun disconnect(onComplete: () -> Unit = {}) { + scope.launch { + disconnectInternal() + onComplete() + } + } + + suspend fun disconnectSuspend() = withContext(Dispatchers.IO) { + disconnectInternal() + } + + @Synchronized + private fun disconnectActiveSocketOnly() { + val job = readJob + readJob = null + job?.cancel() + + val socket = activeSocket + activeSocket = null + if (socket != null && !socket.isClosed) { + try { + socket.shutdownInput() + } catch (_: Exception) {} + try { + socket.shutdownOutput() + } catch (_: Exception) {} + try { + socket.close() + } catch (_: Exception) {} + } + _isConnected.value = false + _connectedHost.value = null + } + + @Synchronized + private fun disconnectInternal() { + connectionGeneration.incrementAndGet() + disconnectActiveSocketOnly() + val sJob = serverJob + serverJob = null + sJob?.cancel() + + val sSocket = serverSocket + serverSocket = null + if (sSocket != null && !sSocket.isClosed) { + try { + sSocket.close() + } catch (_: Exception) {} + } + _isServerRunning.value = false + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt b/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt new file mode 100644 index 0000000..f2d3b95 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt @@ -0,0 +1,100 @@ +package com.dct.app.data.logger + +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 실시간 계측 데이터 CSV 파일 스트리밍 로거 + */ +class CsvMeasurementLogger( + val outputDir: File, + val signalHeaders: List>, // List of (SignalName, Unit) + val customFileName: String? = null +) { + private var writer: BufferedWriter? = null + var outputFile: File? = null + private set + + private var sampleCount: Long = 0 + + @Synchronized + fun start(): File? { + try { + if (!outputDir.exists()) { + outputDir.mkdirs() + } + val fileName = customFileName ?: run { + val timeStamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date()) + "DCT_MEAS_${timeStamp}_measurement.csv" + } + val file = File(outputDir, fileName) + outputFile = file + + val bw = BufferedWriter(FileWriter(file, Charsets.UTF_8), 32 * 1024) + // 헤더 작성: Time(s), Signal1 [unit], Signal2 [unit], ... + val headerLine = buildString { + append("Time(s)") + for ((name, unit) in signalHeaders) { + append(",") + append(name) + if (unit.isNotBlank()) { + append(" [") + append(unit) + append("]") + } + } + } + bw.write(headerLine) + bw.newLine() + bw.flush() + writer = bw + return file + } catch (e: Exception) { + e.printStackTrace() + return null + } + } + + @Synchronized + fun logSample(elapsedSec: Double, values: List) { + val bw = writer ?: return + try { + val line = buildString { + append(String.format(Locale.US, "%.4f", elapsedSec)) + for (v in values) { + append(",") + if (v != null && !v.isNaN() && !v.isInfinite()) { + append(String.format(Locale.US, "%.4f", v)) + } else { + append("0.0000") + } + } + } + bw.write(line) + bw.newLine() + sampleCount++ + if (sampleCount % 30 == 0L) { + bw.flush() + } + } catch (e: IOException) { + e.printStackTrace() + } + } + + @Synchronized + fun stop() { + try { + writer?.flush() + writer?.close() + } catch (e: Exception) { + e.printStackTrace() + } finally { + writer = null + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/logger/MeasurementLogPaths.kt b/Android/app/src/main/java/com/dct/app/data/logger/MeasurementLogPaths.kt new file mode 100644 index 0000000..99ab915 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/logger/MeasurementLogPaths.kt @@ -0,0 +1,23 @@ +package com.dct.app.data.logger + +import java.io.File + +/** PC DPC '/Data' means base-folder relative, not Android's filesystem root. */ +object MeasurementLogPaths { + fun resolve(path: String, baseFolder: String, usesBaseFolder: Boolean, fallbackBase: File): File { + fun usableAbsolute(value: String): Boolean = File(value).isAbsolute && + !value.startsWith("//") && !(File.separatorChar == '/' && Regex("^[A-Za-z]:").containsMatchIn(value)) + val baseText = baseFolder.replace('\\', '/').trim() + val base = if (usableAbsolute(baseText)) File(baseText) else fallbackBase + val normalized = path.replace('\\', '/').trim() + val legacyData = normalized == "/Data" || normalized.startsWith("/Data/") + val foreign = Regex("^[A-Za-z]:").containsMatchIn(normalized) && File.separatorChar == '/' || normalized.startsWith("//") + if (normalized.isNotEmpty() && !usesBaseFolder && !legacyData && !foreign && usableAbsolute(normalized)) { + return File(normalized).normalize() + } + val relative = if (normalized.isBlank() || foreign) "Data" else normalized.trimStart('/') + val candidate = File(base, relative).normalize() + // Reject '..' escaping the selected workspace instead of writing outside it. + return if (candidate.toPath().startsWith(base.normalize().toPath())) candidate else File(base, "Data") + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt b/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt new file mode 100644 index 0000000..8068815 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt @@ -0,0 +1,156 @@ +package com.dct.app.data.logger + +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * ASAM MDF v4.10 (MF4) 실시간 계측 데이터 바이너리 스트리밍 로거 + * Vector CANape, ETAS INCA, MATLAB, Python asammdf 표준 호환 + */ +class Mf4MeasurementLogger( + val outputDir: File, + val signalHeaders: List>, // List of (SignalName, Unit) + val customFileName: String? = null +) { + private var outputStream: BufferedOutputStream? = null + var outputFile: File? = null + private set + + private var recordCount: Long = 0L + private var cgRecordCountOffset: Long = 0L + private val recordSize = 8 + (signalHeaders.size * 8) // 8 bytes time + 8 bytes per signal (Double) + + @Synchronized + fun start(): File? { + try { + if (!outputDir.exists()) { + outputDir.mkdirs() + } + val fileName = customFileName ?: run { + val timeStamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date()) + "DCT_MEAS_${timeStamp}_measurement.mf4" + } + val file = File(outputDir, fileName) + outputFile = file + + val fos = FileOutputStream(file) + val bos = BufferedOutputStream(fos, 64 * 1024) + + // 1. ID Block (64 Bytes) + val idBuf = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN) + idBuf.put("MDF ".toByteArray(Charsets.US_ASCII)) // 0..7 + idBuf.put("4.10 ".toByteArray(Charsets.US_ASCII)) // 8..15 + idBuf.put("DCT_AND ".toByteArray(Charsets.US_ASCII)) // 16..23 + idBuf.putShort(410.toShort()) // 24..25 version 410 + idBuf.putShort(0.toShort()) // 26..27 reserved + idBuf.putInt(0) // 28..31 + idBuf.putLong(0L) // 32..39 + idBuf.putLong(0L) // 40..47 + idBuf.putLong(0L) // 48..55 + idBuf.putLong(0L) // 56..63 + bos.write(idBuf.array()) + + // 2. HD Block (104 Bytes) + val hdBuf = ByteBuffer.allocate(104).order(ByteOrder.LITTLE_ENDIAN) + hdBuf.put("##HD".toByteArray(Charsets.US_ASCII)) + hdBuf.putInt(0) // reserved + hdBuf.putLong(104L) // length of HD block + hdBuf.putLong(1) // link count: 1 link to CG block + hdBuf.putLong(64 + 104L) // link to first CG block (offset 168) + hdBuf.putLong(System.currentTimeMillis() * 1_000_000L) // Start time in nanoseconds + bos.write(hdBuf.array()) + + // 3. CG Block (64 Bytes) + cgRecordCountOffset = (64 + 104 + 32).toLong() // position of cycle_count + val cgBuf = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN) + cgBuf.put("##CG".toByteArray(Charsets.US_ASCII)) + cgBuf.putInt(0) + cgBuf.putLong(64L) // length of CG + cgBuf.putLong(0L) // links + cgBuf.putLong(0L) // record_id + cgBuf.putLong(0L) // cycle_count (updated on stop) + cgBuf.putShort(0.toShort()) // flags + cgBuf.putShort(0.toShort()) // path_separator + cgBuf.putInt(recordSize) // data_bytes (record size) + cgBuf.putInt(0) // invalidation_bytes + bos.write(cgBuf.array()) + + // 4. Data Block Header (##DT) + val dtHeader = ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN) + dtHeader.put("##DT".toByteArray(Charsets.US_ASCII)) + dtHeader.putInt(0) + dtHeader.putLong(0L) // data length (updated on stop) + dtHeader.putLong(0L) // links + bos.write(dtHeader.array()) + + bos.flush() + outputStream = bos + recordCount = 0L + return file + } catch (e: Exception) { + e.printStackTrace() + return null + } + } + + @Synchronized + fun logSample(elapsedSec: Double, values: List) { + val os = outputStream ?: return + try { + val buf = ByteBuffer.allocate(recordSize).order(ByteOrder.LITTLE_ENDIAN) + buf.putDouble(elapsedSec) + for (v in values) { + val num = if (v != null && !v.isNaN() && !v.isInfinite()) v else 0.0 + buf.putDouble(num) + } + os.write(buf.array()) + recordCount++ + if (recordCount % 60L == 0L) { + os.flush() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + @Synchronized + fun stop() { + try { + outputStream?.flush() + outputStream?.close() + } catch (e: Exception) { + e.printStackTrace() + } finally { + outputStream = null + } + + // 헤더에 총 레코드 카운트 및 DT 블록 크기 백필(Backfill) + val file = outputFile ?: return + if (file.exists() && recordCount > 0L) { + try { + RandomAccessFile(file, "rw").use { raf -> + // 1. CG Block cycle_count 갱신 + raf.seek(64 + 104 + 24) // offset of cycle count + val buf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) + buf.putLong(recordCount) + raf.write(buf.array()) + + // 2. DT Block length 갱신 (24 bytes header + recordCount * recordSize) + raf.seek(64 + 104 + 64 + 8) // offset of DT length + val dtLenBuf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) + dtLenBuf.putLong(24L + (recordCount * recordSize)) + raf.write(dtLenBuf.array()) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/maintenance/DeviceMaintenanceProtocol.kt b/Android/app/src/main/java/com/dct/app/data/maintenance/DeviceMaintenanceProtocol.kt new file mode 100644 index 0000000..8b952b0 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/maintenance/DeviceMaintenanceProtocol.kt @@ -0,0 +1,93 @@ +package com.dct.app.data.maintenance + +import com.dct.app.domain.model.protocol.* +import java.io.ByteArrayOutputStream + +/** PC MainFrm/CDIDInformationDlg/RemoveDBDlg wire formats. No UI or automatic mutations. */ +object DeviceMaintenanceProtocol { + fun currentStatus() = DctPacket.create(DctCommand.CMD_CUR_STATUS) + fun possibleToConnect() = DctPacket.create(DctCommand.CMD_POSSIBLE_TO_CONNECT) + fun readDid(did: Int) = DctPacket.create(DctCommand.CMD_REQ_DID_INFO, didBytes(did)) + fun writeDid(did: Int, bytes: ByteArray): DctPacket { + require(bytes.size in 1..255) + return DctPacket.create(DctCommand.CMD_DID_INFO, didBytes(did) + byteArrayOf(bytes.size.toByte()) + bytes) + } + fun setupDid(mask: Int) = DctPacket.create(DctCommand.CMD_SETUP_DID_INFO, byteArrayOf(u8(mask))) + fun readAllDid(mask: Int) = DctPacket.create(DctCommand.CMD_REQ_ALL_DID_INFO, byteArrayOf(u8(mask))) + fun databaseName(name: String, create: Boolean = false) = DctPacket.create( + DctCommand.CMD_DB, byteArrayOf(if (create) 4 else 0) + sizedAscii(name)) + fun databaseCount() = DctPacket.create(DctCommand.CMD_DB, byteArrayOf(1)) + fun databaseItem(index: Int, delete: Boolean = false) = DctPacket.create( + DctCommand.CMD_DB, byteArrayOf(if (delete) 3 else 2, u8(index))) + fun carTypeControl(maker: Int, operation: Int, index: Int = 0): DctPacket { + require(operation in 0..2) + return DctPacket.create(DctCommand.CMD_CARTYPE_CONTROL, + byteArrayOf(operation.toByte(), u8(maker)) + if (operation == 0) byteArrayOf() else byteArrayOf(u8(index))) + } + fun savedInterval() = DctPacket.create(DctCommand.CMD_DCT_ENV, byteArrayOf(0)) + fun setSavedInterval(enabled: Boolean, interval: Int): DctPacket { + require(interval in setOf(0, 1, 2, 5, 10)) + return DctPacket.create(DctCommand.CMD_DCT_ENV, byteArrayOf(1, if (enabled) 1 else 2, interval.toByte())) + } + fun hardwareEnvironment() = DctPacket.create(DctCommand.CMD_DCT_ENV, byteArrayOf(3)) + fun setHardwareEnvironment(beep: Boolean, haptic: Boolean, sensitivity: Int, password: String) = + DctPacket.create(DctCommand.CMD_DCT_ENV, + byteArrayOf(4, if (beep) 1 else 2, if (haptic) 1 else 2, u8(sensitivity)) + sizedAscii(password, allowEmpty = true)) + + data class MemoryRegion(val address: Long, val size: Long) + fun firmwareAddresses(bankA: List, bankB: List): DctPacket { + val out = ByteArrayOutputStream() + for (bank in listOf(bankA, bankB)) { + require(bank.size <= 255) + out.write(bank.size) + bank.forEach { region -> + require(region.address in 0..0xFFFFFFFFL && region.size in 1..0xFFFFFFFFL && + region.address + region.size <= 0x1_0000_0000L) { "펌웨어 메모리 영역 범위 오류" } + for (value in listOf(region.address, region.size)) for (shift in 3 downTo 0) out.write((value shr (shift * 8)).toInt()) + } + } + return DctPacket.create(DctCommand.CMD_FW_ASK_ADDRESS, out.toByteArray()) + } + + data class HashInfo(val fileName: String, val digest: ByteArray, val section: String) + /** Actual PC SendHashValue sends 0x32/subtype 0x03, not the unused 0x33 enum. */ + fun hashResponse(info: HashInfo?): DctPacket { + if (info == null) return DctPacket.create(DctCommand.CMD_FW_KEY_VALUE, byteArrayOf(3, 2)) + require(info.digest.size == 20 || info.digest.size == 32) { "SHA-160/256 해시 길이 오류" } + return DctPacket.create(DctCommand.CMD_FW_KEY_VALUE, + byteArrayOf(3, 1) + sizedAscii(info.fileName) + + byteArrayOf(if (info.digest.size == 32) 2 else 1, info.digest.size.toByte()) + info.digest + sizedAscii(info.section)) + } + + fun parseDid(data: ByteArray, did: Int): ByteArray { + require(data.size >= 4 && data[0] == didBytes(did)[0] && data[1] == didBytes(did)[1] && data[2] == 1.toByte()) { + "DID 응답 주소/상태 오류" + } + require(data.size == 4 + (data[3].toInt() and 255)) { "DID 응답 길이 오류" } + return data.copyOfRange(4, data.size) + } + fun parseAllDid(data: ByteArray): Map { + require(data.firstOrNull() == 1.toByte()) { "DID 일괄 읽기 거절" } + val result = linkedMapOf() + var index = 1 + while (index < data.size) { + require(index + 3 <= data.size) { "DID 헤더가 잘렸습니다." } + val did = ((data[index].toInt() and 255) shl 8) or (data[index + 1].toInt() and 255) + val length = data[index + 2].toInt() and 255 + index += 3 + require(index + length <= data.size && did !in result) { "DID 데이터 길이/중복 오류" } + result[did] = data.copyOfRange(index, index + length) + index += length + } + return result + } + private fun didBytes(did: Int): ByteArray { + require(did in 0..65535) + return byteArrayOf((did shr 8).toByte(), did.toByte()) + } + private fun u8(value: Int): Byte { require(value in 0..255); return value.toByte() } + private fun sizedAscii(value: String, allowEmpty: Boolean = false): ByteArray { + require(value.length <= 255 && (allowEmpty || value.isNotEmpty()) && value.all { it.code in 32..126 }) { "ASCII 문자열 길이/문자 오류" } + return byteArrayOf(value.length.toByte()) + value.toByteArray(Charsets.US_ASCII) + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/maintenance/FirmwareFileParser.kt b/Android/app/src/main/java/com/dct/app/data/maintenance/FirmwareFileParser.kt new file mode 100644 index 0000000..4e65880 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/maintenance/FirmwareFileParser.kt @@ -0,0 +1,136 @@ +package com.dct.app.data.maintenance + +import com.dct.app.domain.model.maintenance.FirmwareFormat +import com.dct.app.domain.model.maintenance.FirmwareImage +import java.util.TreeMap + +/** Intel HEX/S19 체크섬과 주소를 검증하고 장비 전송용 연속 이미지로 정규화합니다. */ +object FirmwareFileParser { + private const val MAX_SOURCE_BYTES = 64 * 1024 * 1024 + private const val MAX_IMAGE_BYTES = 64 * 1024 * 1024 + + fun parse(fileName: String, source: ByteArray): FirmwareImage { + require(source.isNotEmpty()) { "펌웨어 파일이 비어 있습니다." } + require(source.size <= MAX_SOURCE_BYTES) { "펌웨어 원본은 64MB를 초과할 수 없습니다." } + return when (fileName.substringAfterLast('.', "").lowercase()) { + "bin" -> FirmwareImage(fileName, FirmwareFormat.BIN, source.size.toLong(), 0, source.copyOf()) + "hex", "ihex" -> parseIntelHex(fileName, source) + "s19", "srec", "s28", "s37", "mot" -> parseSRecord(fileName, source) + else -> throw IllegalArgumentException("지원하지 않는 펌웨어 형식입니다: $fileName") + } + } + + private fun parseIntelHex(fileName: String, source: ByteArray): FirmwareImage { + val memory = TreeMap() + var addressBase = 0L + var eofSeen = false + lines(source).forEachIndexed { index, line -> + if (line.isBlank()) return@forEachIndexed + require(!eofSeen) { "HEX EOF 뒤에 데이터가 있습니다 (${index + 1}행)." } + require(line.startsWith(':')) { "HEX 시작 문자가 없습니다 (${index + 1}행)." } + val record = decodeHex(line.drop(1), "HEX ${index + 1}행") + require(record.size >= 5) { "HEX 레코드가 너무 짧습니다 (${index + 1}행)." } + val count = u8(record[0]) + require(record.size == count + 5) { "HEX 길이가 일치하지 않습니다 (${index + 1}행)." } + require(record.sumOf(::u8) and 0xFF == 0) { "HEX 체크섬 오류 (${index + 1}행)." } + val offset = (u8(record[1]) shl 8) or u8(record[2]) + val type = u8(record[3]) + val data = record.copyOfRange(4, 4 + count) + when (type) { + 0x00 -> data.forEachIndexed { dataIndex, byte -> putUnique(memory, addressBase + offset + dataIndex, byte, index + 1) } + 0x01 -> { + require(count == 0) { "HEX EOF 길이가 잘못되었습니다 (${index + 1}행)." } + eofSeen = true + } + 0x02 -> { + require(count == 2 && offset == 0) { "HEX 확장 세그먼트 레코드가 잘못되었습니다 (${index + 1}행)." } + addressBase = (((u8(data[0]) shl 8) or u8(data[1])).toLong() shl 4) + } + 0x04 -> { + require(count == 2 && offset == 0) { "HEX 확장 선형 주소 레코드가 잘못되었습니다 (${index + 1}행)." } + addressBase = (((u8(data[0]) shl 8) or u8(data[1])).toLong() shl 16) + } + 0x03, 0x05 -> require(count == 4) { "HEX 시작 주소 레코드가 잘못되었습니다 (${index + 1}행)." } + else -> throw IllegalArgumentException("지원하지 않는 HEX 레코드 타입 0x${type.toString(16)} (${index + 1}행).") + } + } + require(eofSeen) { "HEX EOF 레코드가 없습니다." } + // PC FirmwareUploadDlg의 활성 HEX_ROM_512K 상수는 실제로 512*1024*4(2MB)이며 + // 마지막 데이터 뒤를 0xFF로 채워 그 크기까지 전송한다. + return buildImage(fileName, FirmwareFormat.INTEL_HEX, source.size.toLong(), memory, 0xFF, INTEL_HEX_TRANSFER_BYTES) + } + + private fun parseSRecord(fileName: String, source: ByteArray): FirmwareImage { + val memory = TreeMap() + var terminationSeen = false + lines(source).forEachIndexed { index, line -> + if (line.isBlank()) return@forEachIndexed + require(!terminationSeen) { "S-Record 종료 레코드 뒤에 데이터가 있습니다 (${index + 1}행)." } + require(line.length >= 4 && line[0] == 'S') { "S-Record 시작 문자가 없습니다 (${index + 1}행)." } + val type = line[1] + val record = decodeHex(line.drop(2), "S-Record ${index + 1}행") + require(record.isNotEmpty()) { "S-Record가 너무 짧습니다 (${index + 1}행)." } + val count = u8(record[0]) + require(record.size == count + 1) { "S-Record 길이가 일치하지 않습니다 (${index + 1}행)." } + require(record.sumOf(::u8) and 0xFF == 0xFF) { "S-Record 체크섬 오류 (${index + 1}행)." } + val addressBytes = when (type) { + '0', '1', '5', '9' -> 2 + '2', '6', '8' -> 3 + '3', '7' -> 4 + else -> throw IllegalArgumentException("지원하지 않는 S-Record 타입 S$type (${index + 1}행).") + } + require(count >= addressBytes + 1) { "S-Record 주소 길이가 잘못되었습니다 (${index + 1}행)." } + var address = 0L + repeat(addressBytes) { address = (address shl 8) or u8(record[1 + it]).toLong() } + val dataLength = count - addressBytes - 1 + when (type) { + '1', '2', '3' -> repeat(dataLength) { dataIndex -> + putUnique(memory, address + dataIndex, record[1 + addressBytes + dataIndex], index + 1) + } + '7', '8', '9' -> terminationSeen = true + } + } + require(terminationSeen) { "S-Record 종료 레코드가 없습니다." } + return buildImage(fileName, FirmwareFormat.MOTOROLA_S19, source.size.toLong(), memory, 0x00) + } + + private fun buildImage( + fileName: String, + format: FirmwareFormat, + sourceLength: Long, + memory: TreeMap, + gapFill: Int, + minimumSize: Int = 0 + ): FirmwareImage { + require(memory.isNotEmpty()) { "펌웨어 데이터 레코드가 없습니다." } + val start = memory.firstKey() + val end = memory.lastKey() + val populatedSize = end - start + 1 + val size = maxOf(populatedSize, minimumSize.toLong()) + require(start in 0..0xFFFF_FFFFL && end in 0..0xFFFF_FFFFL) { "펌웨어 주소가 32비트 범위를 벗어났습니다." } + require(size in 1..MAX_IMAGE_BYTES.toLong()) { "정규화된 펌웨어 이미지는 64MB를 초과할 수 없습니다." } + val bytes = ByteArray(size.toInt()) { gapFill.toByte() } + memory.forEach { (address, value) -> bytes[(address - start).toInt()] = value } + return FirmwareImage(fileName, format, sourceLength, start, bytes) + } + + private fun putUnique(memory: MutableMap, address: Long, value: Byte, line: Int) { + val previous = memory.putIfAbsent(address, value) + require(previous == null || previous == value) { "주소 0x${address.toString(16)}의 데이터가 충돌합니다 ($line 행)." } + } + + private fun lines(source: ByteArray): List = + source.toString(Charsets.US_ASCII).lineSequence().map(String::trim).toList() + + private fun decodeHex(value: String, label: String): ByteArray { + require(value.length % 2 == 0) { "$label 16진수 길이가 홀수입니다." } + return ByteArray(value.length / 2) { index -> + value.substring(index * 2, index * 2 + 2).toIntOrNull(16)?.toByte() + ?: throw IllegalArgumentException("$label 16진수 문자가 잘못되었습니다.") + } + } + + private fun u8(value: Byte): Int = value.toInt() and 0xFF + + private const val INTEL_HEX_TRANSFER_BYTES = 512 * 1024 * 4 +} diff --git a/Android/app/src/main/java/com/dct/app/data/maintenance/MaintenanceService.kt b/Android/app/src/main/java/com/dct/app/data/maintenance/MaintenanceService.kt new file mode 100644 index 0000000..55c483f --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/maintenance/MaintenanceService.kt @@ -0,0 +1,366 @@ +package com.dct.app.data.maintenance + +import com.dct.app.core.common.DctResult +import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.domain.model.maintenance.FirmwareFormat +import com.dct.app.domain.model.maintenance.FirmwareImage +import com.dct.app.domain.model.maintenance.RemoteFirmwareFile +import com.dct.app.domain.model.maintenance.RemoteLogFile +import com.dct.app.domain.model.maintenance.SeedKeyConfiguration +import com.dct.app.domain.model.maintenance.SeedKeyState +import com.dct.app.domain.model.maintenance.TransferProgress +import com.dct.app.domain.model.protocol.DctCommand +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.PacketRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** P3 보안 인증, 펌웨어 업로드, 플래시 로그 다운로드의 실장비 상태 머신. */ +class MaintenanceService( + private val packetRepository: PacketRepository, + private var seedKeyProvider: SeedKeyProvider = UnavailableSeedKeyProvider(), + private var seedKeyConfiguration: SeedKeyConfiguration = SeedKeyConfiguration() +) { + private val _seedKeyState = MutableStateFlow(SeedKeyState.Idle) + val seedKeyState: StateFlow = _seedKeyState.asStateFlow() + + private val _transferProgress = MutableStateFlow(TransferProgress()) + val transferProgress: StateFlow = _transferProgress.asStateFlow() + + private var seedListenerJob: Job? = null + private var hashInfo: DeviceMaintenanceProtocol.HashInfo? = null + + fun configureHash(info: DeviceMaintenanceProtocol.HashInfo?) { + DeviceMaintenanceProtocol.hashResponse(info) // validate before retaining configuration + hashInfo = info?.copy(digest = info.digest.copyOf()) + } + + suspend fun readDid(did: Int): ByteArray { + requireReady() + return DeviceMaintenanceProtocol.parseDid(exchange(DeviceMaintenanceProtocol.readDid(did)).payload, did) + } + + suspend fun writeDid(did: Int, value: ByteArray) { + requireReady() + DeviceMaintenanceProtocol.parseDid(exchange(DeviceMaintenanceProtocol.writeDid(did, value)).payload, did) + } + + suspend fun readAllDid(mask: Int): Map { + requireReady() + return DeviceMaintenanceProtocol.parseAllDid(exchange(DeviceMaintenanceProtocol.readAllDid(mask)).payload) + } + + suspend fun setupDid(mask: Int) { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.setupDid(mask)).payload + require(data.firstOrNull() == 1.toByte()) { "DID 프리셋 설정 거절" } + } + + suspend fun setFirmwareAddresses(bankA: List, bankB: List) { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.firmwareAddresses(bankA, bankB)).payload + require(data.firstOrNull() == 1.toByte()) { "펌웨어 주소 설정 거절" } + } + + suspend fun listDatabases(): List { + requireReady() + val count = exchange(DeviceMaintenanceProtocol.databaseCount()).payload + require(count.size == 3 && count[0] == 1.toByte() && count[1] == 1.toByte()) { "DB 개수 응답 오류" } + return (0 until u8(count[2])).map { index -> + val data = exchange(DeviceMaintenanceProtocol.databaseItem(index)).payload + require(data.size >= 4 && data[0] == 2.toByte() && data[1] == 1.toByte() && u8(data[2]) == index && data.size == 4 + u8(data[3])) { "DB 정보 응답 오류" } + data.copyOfRange(4, data.size).toString(Charsets.US_ASCII) + } + } + + suspend fun deleteDatabase(index: Int) { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.databaseItem(index, delete = true)).payload + require(data.size >= 2 && data[0] == 3.toByte() && data[1] == 1.toByte()) { "DB 삭제 거절" } + } + + suspend fun selectDatabase(name: String, create: Boolean = false) { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.databaseName(name, create)).payload + require(data.size >= 2 && data[0] == (if (create) 4 else 0).toByte() && data[1] == 1.toByte()) { "DB 설정 거절" } + } + + suspend fun listCarTypes(maker: Int): List { + requireReady() + val count = exchange(DeviceMaintenanceProtocol.carTypeControl(maker, 0)).payload + require(count.size == 4 && count[0] == 0.toByte() && u8(count[1]) == maker && count[2] == 1.toByte()) { "차종 개수 응답 오류" } + return (0 until u8(count[3])).map { index -> + val data = exchange(DeviceMaintenanceProtocol.carTypeControl(maker, 1, index)).payload + require(data.size >= 5 && data[0] == 1.toByte() && u8(data[1]) == maker && data[2] == 1.toByte() && u8(data[3]) == index && data.size == 5 + u8(data[4])) { "차종 정보 응답 오류" } + data.copyOfRange(5, data.size).toString(Charsets.US_ASCII) + } + } + + suspend fun deleteCarType(maker: Int, index: Int) { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.carTypeControl(maker, 2, index)).payload + require(data.size >= 3 && data[0] == 2.toByte() && u8(data[1]) == maker && data[2] == 1.toByte()) { "차종 삭제 거절" } + } + + suspend fun readSavingInterval(): Pair { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.savedInterval()).payload + require(data.size == 4 && data[0] == 0.toByte() && data[1] == 1.toByte() && u8(data[2]) in 1..2) { "저장 주기 응답 오류" } + return (data[2] == 1.toByte()) to u8(data[3]) + } + + suspend fun setSavingInterval(enabled: Boolean, interval: Int) { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.setSavedInterval(enabled, interval)).payload + require(data.size == 2 && data[0] == 1.toByte() && data[1] == 1.toByte()) { "저장 주기 설정 거절" } + } + + class HardwareEnvironment(val beep: Boolean, val haptic: Boolean, val sensitivity: Int, val password: String) + + suspend fun readHardwareEnvironment(): HardwareEnvironment { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.hardwareEnvironment()).payload + require(data.size >= 4 && data[0] == 3.toByte() && u8(data[1]) in 1..2 && u8(data[2]) in 1..2) { "장비 환경 응답 오류" } + val password = if (data.size == 4) "" else { + require(data.size == 5 + u8(data[4])) { "장비 환경 응답 길이 오류" } + data.copyOfRange(5, data.size).toString(Charsets.US_ASCII) + } + return HardwareEnvironment(data[1] == 1.toByte(), data[2] == 1.toByte(), u8(data[3]), password) + } + + suspend fun setHardwareEnvironment(environment: HardwareEnvironment) { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.setHardwareEnvironment(environment.beep, environment.haptic, + environment.sensitivity, environment.password)).payload + require(data.size == 2 && data[0] == 4.toByte() && data[1] == 1.toByte()) { "장비 환경 설정 거절" } + } + + suspend fun readDeviceStatus(): Pair { + requireReady() + val data = exchange(DeviceMaintenanceProtocol.currentStatus()).payload + require(data.size == 2 && u8(data[1]) in 0..4) { "장비 상태 응답 오류" } + return u8(data[0]) to u8(data[1]) + } + + /** Probe is valid before the ENABLE handshake; no automatic retry or connection mutation. */ + suspend fun probeConnection(): Boolean { + val result = packetRepository.sendCommand(DeviceMaintenanceProtocol.possibleToConnect()) + return result is DctResult.Success && result.data?.command == DctCommand.RES_POSSIBLE_TO_CONNECT.code && result.data.payload.isEmpty() + } + + fun configureSeedKey(provider: SeedKeyProvider, configuration: SeedKeyConfiguration) { + seedKeyProvider = provider + seedKeyConfiguration = configuration + _seedKeyState.value = SeedKeyState.WaitingForSeed + } + + fun startSeedListener(scope: CoroutineScope) { + if (seedListenerJob?.isActive == true) return + _seedKeyState.value = SeedKeyState.WaitingForSeed + seedListenerJob = scope.launch { + packetRepository.packetStream.collect { packet -> + if (packet.command == DctCommand.SEND_FW_SEEDVALUE.code) { + if (packet.payload.firstOrNull() == 3.toByte()) { + val result = packetRepository.sendCommand(DeviceMaintenanceProtocol.hashResponse(hashInfo)) + if (result is DctResult.Failure) _seedKeyState.value = SeedKeyState.Failed(result.error.message) + } else handleSeed(packet) + } + } + } + } + + fun stopSeedListener() { + seedListenerJob?.cancel() + seedListenerJob = null + _seedKeyState.value = SeedKeyState.Idle + } + + suspend fun listFirmwareFiles(): List { + requireReady() + val countResponse = exchange(DctProtocolRequests.firmwareCount()) + val count = parseCount(countResponse.payload, 0x04, "펌웨어") + require(count <= 0x100) { "장비가 비정상적인 펌웨어 파일 수를 반환했습니다: $count" } + return (0 until count).map { index -> + val payload = exchange(DctProtocolRequests.firmwareInfo(index)).payload + requireSubtype(payload, 0x01, 3, "펌웨어 정보") + val returnedIndex = u8(payload[1]) + val nameLength = u8(payload[2]) + require(returnedIndex == index && payload.size >= 3 + nameLength) { "펌웨어 정보 응답 길이/인덱스가 잘못되었습니다." } + RemoteFirmwareFile(index, payload.copyOfRange(3, 3 + nameLength).toString(Charsets.US_ASCII)) + } + } + + suspend fun uploadFirmware(image: FirmwareImage) { + requireReady() + require(image.bytes.isNotEmpty()) { "전송할 펌웨어 데이터가 없습니다." } + require(image.bytes.size.toLong() <= MAX_FIRMWARE_BYTES) { "펌웨어 이미지는 64MB를 초과할 수 없습니다." } + val chunkCount = (image.bytes.size + FIRMWARE_CHUNK_BYTES - 1) / FIRMWARE_CHUNK_BYTES + require(chunkCount <= 0x10000) { "펌웨어 청크 인덱스가 16비트 범위를 벗어납니다." } + + _transferProgress.value = TransferProgress(0, image.bytes.size.toLong(), true, "펌웨어 메타데이터 전송") + try { + val countPayload = exchange(DctProtocolRequests.firmwareCount()).payload + val slot = parseCount(countPayload, 0x04, "펌웨어") + require(slot in 0..0xFF) { "장비 펌웨어 슬롯이 8비트 범위를 벗어났습니다: $slot" } + val metadata = DctProtocolRequests.firmwareMetadata( + slot = slot, + sourceLength = image.sourceLength, + fileName = image.fileName, + s19StartAddress = image.startAddress.takeIf { image.format == FirmwareFormat.MOTOROLA_S19 } + ) + requireFirmwareAck(exchange(metadata).payload, 0x02, slot, "펌웨어 메타데이터") + + var sent = 0 + var chunkIndex = 0 + while (sent < image.bytes.size) { + val end = minOf(sent + FIRMWARE_CHUNK_BYTES, image.bytes.size) + val data = image.bytes.copyOfRange(sent, end) + val isLast = end == image.bytes.size + val response = exchange(DctProtocolRequests.firmwareChunk(slot, chunkIndex, isLast, data)) + requireFirmwareAck(response.payload, 0x05, slot, "펌웨어 청크 $chunkIndex") + sent = end + chunkIndex++ + _transferProgress.value = TransferProgress(sent.toLong(), image.bytes.size.toLong(), true, "펌웨어 업로드") + } + _transferProgress.value = TransferProgress(image.bytes.size.toLong(), image.bytes.size.toLong(), false, "펌웨어 업로드 완료") + } catch (e: CancellationException) { + _transferProgress.value = _transferProgress.value.copy(isRunning = false, message = "펌웨어 업로드 취소") + throw e + } catch (e: Throwable) { + _transferProgress.value = _transferProgress.value.copy(isRunning = false, message = e.message ?: "펌웨어 업로드 실패") + throw e + } + } + + suspend fun listSavedLogs(): List { + requireReady() + val count = parseCount(exchange(DctProtocolRequests.savedLogCount()).payload, 0x01, "로그") + require(count <= MAX_REMOTE_FILES) { "장비가 비정상적인 로그 파일 수를 반환했습니다: $count" } + return (0 until count).map { index -> + val payload = exchange(DctProtocolRequests.savedLogInfo(index)).payload + requireSubtype(payload, 0x02, 4, "로그 정보") + val returnedIndex = u16be(payload, 1) + val nameLength = u8(payload[3]) + require(returnedIndex == index && payload.size >= 4 + nameLength) { "로그 정보 응답 길이/인덱스가 잘못되었습니다." } + RemoteLogFile(index, payload.copyOfRange(4, 4 + nameLength).toString(Charsets.US_ASCII)) + } + } + + suspend fun downloadSavedLog(file: RemoteLogFile, onChunk: suspend (ByteArray) -> Unit): RemoteLogFile { + requireReady() + val sizePayload = exchange(DctProtocolRequests.savedLogLength(file.index)).payload + requireSubtype(sizePayload, 0x01, 7, "로그 크기") + require(u16be(sizePayload, 1) == file.index) { "로그 크기 응답 인덱스가 일치하지 않습니다." } + val total = u32be(sizePayload, 3) + require(total <= MAX_LOG_BYTES) { "로그 파일 크기가 안전 한도(4GB)를 초과합니다." } + _transferProgress.value = TransferProgress(0, total, true, "로그 다운로드") + try { + var received = 0L + while (received < total) { + val requestLength = minOf(LOG_CHUNK_BYTES.toLong(), total - received).toInt() + val payload = exchange(DctProtocolRequests.savedLogChunk(file.index, received, requestLength)).payload + requireSubtype(payload, 0x02, 6, "로그 데이터") + require(u16be(payload, 1) == file.index) { "로그 데이터 응답 인덱스가 일치하지 않습니다." } + val length = u16be(payload, 3) + val status = u8(payload[5]) + require(status == 0x01 || status == 0x03) { "장비가 로그 청크를 거절했습니다 (status=0x${status.toString(16)})." } + require(length in 1..requestLength && payload.size >= 6 + length) { "로그 청크 길이가 잘못되었습니다." } + require(received + length <= total) { "로그 청크가 공지된 파일 크기를 초과합니다." } + onChunk(payload.copyOfRange(6, 6 + length)) + received += length + _transferProgress.value = TransferProgress(received, total, true, "로그 다운로드") + require(status != 0x03 || received == total) { "장비가 파일 크기보다 일찍 마지막 청크를 보냈습니다." } + require(status != 0x01 || received < total) { "마지막 바이트에 계속 상태가 설정되었습니다." } + } + _transferProgress.value = TransferProgress(total, total, false, "로그 다운로드 완료") + return file.copy(size = total) + } catch (e: CancellationException) { + _transferProgress.value = _transferProgress.value.copy(isRunning = false, message = "로그 다운로드 취소") + throw e + } catch (e: Throwable) { + _transferProgress.value = _transferProgress.value.copy(isRunning = false, message = e.message ?: "로그 다운로드 실패") + throw e + } + } + + private suspend fun handleSeed(packet: DctPacket) { + val payload = packet.payload + if (payload.size < 2 || u8(payload[0]) != 0x01) { + _seedKeyState.value = SeedKeyState.Failed("Seed 응답 형식이 잘못되었습니다.") + packetRepository.sendCommand(DctProtocolRequests.firmwareKeyFailure()) + return + } + val length = u8(payload[1]) + if (length !in 1..8 || payload.size < 2 + length) { + _seedKeyState.value = SeedKeyState.Failed("Seed 길이가 프로토콜 범위를 벗어났습니다.") + packetRepository.sendCommand(DctProtocolRequests.firmwareKeyFailure()) + return + } + try { + val key = seedKeyProvider.generateKey(payload.copyOfRange(2, 2 + length)) + val stMin = if (seedKeyConfiguration.dependsOnRom) 99 else seedKeyConfiguration.stMin + val result = packetRepository.sendCommand( + DctProtocolRequests.firmwareKeySuccess(key, seedKeyConfiguration.firmwareAddress, stMin) + ) + if (result is DctResult.Failure) throw IllegalStateException(result.error.message, result.cause) + _seedKeyState.value = SeedKeyState.Authenticated(key.size) + } catch (e: Throwable) { + if (e is CancellationException) throw e + packetRepository.sendCommand(DctProtocolRequests.firmwareKeyFailure()) + _seedKeyState.value = if (e is UnsatisfiedLinkError || e is IllegalStateException && e.message?.contains("라이브러리") == true) { + SeedKeyState.Unavailable(e.message ?: "HKMC ASK 라이브러리를 사용할 수 없습니다.") + } else { + SeedKeyState.Failed(e.message ?: "Seed-Key 생성 실패") + } + } + } + + private fun requireReady() { + require(packetRepository.connectionState.value == DeviceConnectionState.READY) { + "유지보수 기능은 Wi-Fi 장비가 READY 상태이고 계측이 정지된 경우에만 사용할 수 있습니다." + } + } + + private suspend fun exchange(request: DctPacket): DctPacket = when (val result = packetRepository.sendCommand(request)) { + is DctResult.Success -> result.data ?: throw IllegalStateException("장비 응답이 없습니다.") + is DctResult.Failure -> throw IllegalStateException(result.error.message, result.cause) + DctResult.Loading -> throw IllegalStateException("장비 요청이 완료되지 않았습니다.") + } + + private fun parseCount(payload: ByteArray, subtype: Int, name: String): Int { + requireSubtype(payload, subtype, 3, "$name 파일 수") + return u16be(payload, 1) + } + + private fun requireFirmwareAck(payload: ByteArray, subtype: Int, slot: Int, label: String) { + requireSubtype(payload, subtype, 3, label) + require(u8(payload[1]) == slot) { "$label 응답 슬롯이 일치하지 않습니다." } + require(u8(payload[2]) == 0x01) { "장비가 $label 전송을 거절했습니다." } + } + + private fun requireSubtype(payload: ByteArray, subtype: Int, minSize: Int, label: String) { + require(payload.size >= minSize && u8(payload[0]) == subtype) { "$label 응답 형식이 잘못되었습니다." } + } + + private fun u8(value: Byte): Int = value.toInt() and 0xFF + private fun u16be(data: ByteArray, offset: Int): Int = (u8(data[offset]) shl 8) or u8(data[offset + 1]) + private fun u32be(data: ByteArray, offset: Int): Long = + (u8(data[offset]).toLong() shl 24) or + (u8(data[offset + 1]).toLong() shl 16) or + (u8(data[offset + 2]).toLong() shl 8) or + u8(data[offset + 3]).toLong() + + companion object { + private const val FIRMWARE_CHUNK_BYTES = 512 + private const val LOG_CHUNK_BYTES = 4 * 1024 + private const val MAX_REMOTE_FILES = 4096 + private const val MAX_FIRMWARE_BYTES = 64L * 1024 * 1024 + private const val MAX_LOG_BYTES = 0xFFFF_FFFFL + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/maintenance/SeedKeyProvider.kt b/Android/app/src/main/java/com/dct/app/data/maintenance/SeedKeyProvider.kt new file mode 100644 index 0000000..1855217 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/maintenance/SeedKeyProvider.kt @@ -0,0 +1,38 @@ +package com.dct.app.data.maintenance + +/** HKMC ASK 구현을 앱 통신 코드와 분리하는 공급자 경계. */ +fun interface SeedKeyProvider { + @Throws(Exception::class) + fun generateKey(seed: ByteArray): ByteArray +} + +class UnavailableSeedKeyProvider( + private val reason: String = "Android용 HKMC ASK 네이티브 라이브러리가 설치되지 않았습니다." +) : SeedKeyProvider { + override fun generateKey(seed: ByteArray): ByteArray = throw IllegalStateException(reason) +} + +/** + * 벤더가 제공하는 Android JNI 라이브러리(libHKMC_ASK.so)를 사용하는 공급자. + * 라이브러리와 JNI 심볼이 모두 제공된 빌드에서만 생성해 주입해야 합니다. + */ +class NativeSeedKeyProvider private constructor() : SeedKeyProvider { + override fun generateKey(seed: ByteArray): ByteArray { + require(seed.isNotEmpty() && seed.size <= MAX_SEED_LENGTH) { "Seed length must be 1..$MAX_SEED_LENGTH" } + val key = nativeGenerateKey(seed) + require(key.isNotEmpty() && key.size <= MAX_KEY_LENGTH) { "Native ASK returned an invalid key length" } + return key + } + + private external fun nativeGenerateKey(seed: ByteArray): ByteArray + + companion object { + private const val MAX_SEED_LENGTH = 8 + private const val MAX_KEY_LENGTH = 8 + + fun load(): Result = runCatching { + System.loadLibrary("HKMC_ASK") + NativeSeedKeyProvider() + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/mdb/AttrConverter.kt b/Android/app/src/main/java/com/dct/app/data/mdb/AttrConverter.kt new file mode 100644 index 0000000..55cfdad --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/mdb/AttrConverter.kt @@ -0,0 +1,133 @@ +package com.dct.app.data.mdb + +import com.dct.app.domain.model.mdb.MdbAttrType +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * ATTR 0~5 바이너리 데이터 언팩/패킹 및 물리값 선형 변환 엔진 + * + * C++ PC 진실의 원천(refer/dct_sw_xcp_pc): + * - LibDB.cpp:1276 (GetItemSize) + * - MainFrm.cpp:5385 (바이너리 패킷 언패킹) + * - CConvertToCSV.cpp:706 (물리값 선형 변환 수식: Y = A * X + B) + */ +object AttrConverter { + + /** + * 바이트 배열로부터 지정된 오프셋 및 ATTR 속성에 맞게 Little-Endian 원시 정수값을 언팩 + * + * @param buffer 데이터 바이트 배열 + * @param offset 시작 바이트 오프셋 + * @param attrType ATTR 타입 (ATTR_0_UINT8 ~ ATTR_5_INT32) + * @return 언팩된 부호/비부호 정수값 (Long 타입으로 반환하여 Unsigned 32비트 표현 보장) + */ + fun unpackRawValue(buffer: ByteArray, offset: Int, attrType: MdbAttrType): Long { + require(offset >= 0 && offset + attrType.byteSize <= buffer.size) { + "Buffer underflow: buffer size=${buffer.size}, offset=$offset, required=${attrType.byteSize}" + } + + return when (attrType) { + MdbAttrType.ATTR_0_UINT8 -> { + buffer[offset].toLong() and 0xFFL + } + MdbAttrType.ATTR_1_INT8 -> { + buffer[offset].toLong() + } + MdbAttrType.ATTR_2_UINT16 -> { + val b0 = buffer[offset].toLong() and 0xFFL + val b1 = buffer[offset + 1].toLong() and 0xFFL + b0 or (b1 shl 8) + } + MdbAttrType.ATTR_3_INT16 -> { + val b0 = buffer[offset].toInt() and 0xFF + val b1 = buffer[offset + 1].toInt() and 0xFF + val raw = (b0 or (b1 shl 8)).toShort() + raw.toLong() + } + MdbAttrType.ATTR_4_UINT32 -> { + val b0 = buffer[offset].toLong() and 0xFFL + val b1 = buffer[offset + 1].toLong() and 0xFFL + val b2 = buffer[offset + 2].toLong() and 0xFFL + val b3 = buffer[offset + 3].toLong() and 0xFFL + b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24) + } + MdbAttrType.ATTR_5_INT32 -> { + val b0 = buffer[offset].toInt() and 0xFF + val b1 = buffer[offset + 1].toInt() and 0xFF + val b2 = buffer[offset + 2].toInt() and 0xFF + val b3 = buffer[offset + 3].toInt() and 0xFF + val raw = b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24) + raw.toLong() + } + } + } + + /** + * 정수 원시값을 Little-Endian 바이트 배열로 패킹 + * + * @param value 원시 정수값 + * @param attrType 대상 ATTR 타입 + * @param buffer 대상 바이트 배열 + * @param offset 저장 시작 오프셋 + */ + fun packRawValue(value: Long, attrType: MdbAttrType, buffer: ByteArray, offset: Int) { + require(offset >= 0 && offset + attrType.byteSize <= buffer.size) { + "Buffer underflow: buffer size=${buffer.size}, offset=$offset, required=${attrType.byteSize}" + } + + when (attrType) { + MdbAttrType.ATTR_0_UINT8, MdbAttrType.ATTR_1_INT8 -> { + buffer[offset] = (value and 0xFFL).toByte() + } + MdbAttrType.ATTR_2_UINT16, MdbAttrType.ATTR_3_INT16 -> { + buffer[offset] = (value and 0xFFL).toByte() + buffer[offset + 1] = ((value shr 8) and 0xFFL).toByte() + } + MdbAttrType.ATTR_4_UINT32, MdbAttrType.ATTR_5_INT32 -> { + buffer[offset] = (value and 0xFFL).toByte() + buffer[offset + 1] = ((value shr 8) and 0xFFL).toByte() + buffer[offset + 2] = ((value shr 16) and 0xFFL).toByte() + buffer[offset + 3] = ((value shr 24) and 0xFFL).toByte() + } + } + } + + /** + * 원시 정수값을 새로운 바이트 배열(크기: attrType.byteSize)로 패킹하여 반환 + */ + fun packRawValue(value: Long, attrType: MdbAttrType): ByteArray { + val result = ByteArray(attrType.byteSize) + packRawValue(value, attrType, result, 0) + return result + } + + /** + * 원시값 -> 물리값 선형 변환: Y = A * X + B + */ + fun toPhysicalValue(rawValue: Number, cvsA: Double, cvsB: Double): Double { + return (rawValue.toDouble() * cvsA) + cvsB + } + + /** + * 물리값 -> 원시값 선형 역변환: X = (Y - B) / A + */ + fun toRawValue(physicalValue: Double, cvsA: Double, cvsB: Double): Double { + if (cvsA == 0.0) return 0.0 + return (physicalValue - cvsB) / cvsA + } + + /** + * 바이너리 바이트 버퍼로부터 직접 물리값을 언팩 및 계산 + */ + fun unpackPhysicalValue( + buffer: ByteArray, + offset: Int, + attrType: MdbAttrType, + cvsA: Double, + cvsB: Double + ): Double { + val raw = unpackRawValue(buffer, offset, attrType) + return toPhysicalValue(raw, cvsA, cvsB) + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/mdb/MdbSchemaParser.kt b/Android/app/src/main/java/com/dct/app/data/mdb/MdbSchemaParser.kt new file mode 100644 index 0000000..b8aac7d --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/mdb/MdbSchemaParser.kt @@ -0,0 +1,374 @@ +package com.dct.app.data.mdb + +import com.healthmarketscience.jackcess.Database +import com.healthmarketscience.jackcess.Row +import com.healthmarketscience.jackcess.Table +import com.dct.app.domain.model.mdb.* + +/** + * C++ LibDB.cpp 100% 호환 MDB 스키마 파서 + * + * 처리 내용: + * 1. VarNif 신호 그룹 메타데이터 파싱 (RECLID 정렬) + * 2. VarLst 신호 정의 파싱 및 ADL 처리 (RECLID, IDX 정렬) + * 3. CalculateVarLstItemIndex 알고리즘 기반 arrayIndex 오프셋 누적 계산 + * 4. ConLst 캘리브레이션 상수 파싱 + * 5. Tb1DmPxDyLst (Type A), Tb1DmPyDxLst (Type B) 및 동적 [YNAME] 포인트 테이블 파싱 (IDX vs ID 호환) + */ +object MdbSchemaParser { + + /** + * 열린 Jackcess Database로부터 전체 MdbDatabase 도메인 객체 트리 구성 + */ + fun parseDatabase(db: Database, fileName: String = ""): MdbDatabase { + val rawTableNames = db.tableNames + .filter { !it.startsWith("MSys") && !it.startsWith("~") } + .sorted() + + // 1. VarNif 및 VarLst 파싱 & 계층화 + val signalGroups = parseSignalGroups(db) + + // 2. ConLst 파싱 + val constants = parseConstants(db) + + // 3. 1D 보정 맵 파싱 (Type A & Type B) + val compensationMaps = parseCompensationMaps(db) + + return MdbDatabase( + fileName = fileName, + groups = signalGroups, + constants = constants, + compensationMaps = compensationMaps, + rawTableNames = rawTableNames + ) + } + + /** + * VarNif 및 VarLst를 파싱하고 C++ CalculateVarLstItemIndex() 알고리즘 적용 + */ + private fun parseSignalGroups(db: Database): List { + val varNifTable = db.getTable("VarNif") + val varLstTable = db.getTable("VarLst") + + val rawGroups = mutableListOf() + if (varNifTable != null) { + for (row in varNifTable) { + val reclid = getNumber(row, "RECLID")?.toInt() ?: continue + val strIdx = getString(row, "IDX") ?: "0" + val desc = getString(row, "DESCRIP") ?: "" + val usage = getBoolean(row, "USAGE") ?: true + + rawGroups.add( + SignalGroup( + reclid = reclid, + strIndex = strIdx, + description = desc, + usage = usage + ) + ) + } + } + + val rawSignals = mutableListOf() + if (varLstTable != null) { + var prevIdx = -1 + for (row in varLstTable) { + val reclid = getNumber(row, "RECLID")?.toInt() ?: continue + var idx = getNumber(row, "IDX")?.toInt() ?: 0 + val adl = getNumber(row, "ADL")?.toInt() ?: 0 + + // C++ LibDB.cpp:665 (중복 인덱스 처리) + if (prevIdx == idx && adl != 0) { + idx = adl + } + prevIdx = idx + + val name = getString(row, "NAME") ?: "SIGNAL_$idx" + val attrCode = getNumber(row, "ATTR")?.toInt() ?: 0 + val attrType = MdbAttrType.fromCode(attrCode) + val cvsA = getDouble(row, "CVS_A") ?: 1.0 + val cvsB = getDouble(row, "CVS_B") ?: 0.0 + val cvsT = getString(row, "CVS_T") ?: "" + val rawUnit = getString(row, "UNIT") ?: "" + val unit = com.dct.app.core.util.TextStreamDecoder.sanitizeUnit(rawUnit) + val color = getNumber(row, "COLOR")?.toLong() ?: 0L + val min = getDouble(row, "MIN") ?: 0.0 + val max = getDouble(row, "MAX") ?: 100.0 + val description = getString(row, "DESCRIPTION") ?: "" + + rawSignals.add( + SignalDefinition( + reclid = reclid, + idx = idx, + name = name, + attr = attrType, + cvsA = cvsA, + cvsB = cvsB, + unit = unit, + color = color, + min = min, + max = max, + description = description, + cvsT = cvsT, + adl = adl, + itemSize = attrType.byteSize, + arrayIndex = 0 + ) + ) + } + } + + // C++ CalculateVarLstItemIndex 알고리즘 구현 + // 1) 그룹: RECLID 오름차순 정렬 + val sortedGroups = rawGroups.sortedBy { it.reclid }.toMutableList() + // 2) 신호: RECLID 오름차순 -> IDX 오름차순 정렬 + val sortedSignals = rawSignals.sortedWith(compareBy({ it.reclid }, { it.idx })) + + val finalGroups = mutableListOf() + + if (sortedGroups.isEmpty() && sortedSignals.isNotEmpty()) { + // VarNif 테이블이 없는 경우 VarLst의 RECLID별로 자동 그룹 생성 + val distinctReclids = sortedSignals.map { it.reclid }.distinct().sorted() + for (r in distinctReclids) { + sortedGroups.add( + SignalGroup( + reclid = r, + strIndex = r.toString(), + description = "Group_$r", + usage = true + ) + ) + } + } + + for (group in sortedGroups) { + val matchingSignals = sortedSignals.filter { it.reclid == group.reclid } + var currentOffset = 0 + var first = true + + val processedSignals = mutableListOf() + for (sig in matchingSignals) { + if (first) { + group.startIndex = sig.idx + first = false + } + sig.arrayIndex = currentOffset + currentOffset += sig.itemSize + processedSignals.add(sig) + } + group.itemCount = processedSignals.size + + finalGroups.add( + group.copy( + startIndex = group.startIndex, + itemCount = group.itemCount, + signals = processedSignals + ) + ) + } + + return finalGroups + } + + /** + * ConLst 테이블 파싱 + */ + private fun parseConstants(db: Database): List { + val conTable = db.getTable("ConLst") ?: return emptyList() + val constants = mutableListOf() + + for (row in conTable) { + val idx = getNumber(row, "IDX")?.toInt() ?: getNumber(row, "ID")?.toInt() ?: constants.size + val name = getString(row, "NAME") ?: getString(row, "VAR_NAME") ?: "CONST_$idx" + val data = getDouble(row, "DATA") ?: getDouble(row, "CURRENT_VAL") ?: getDouble(row, "DEFAULT_VAL") ?: 0.0 + val addr = getNumber(row, "ADDR")?.toLong() ?: 0L + val min = getDouble(row, "MIN") ?: getDouble(row, "MIN_LIMIT") ?: 0.0 + val max = getDouble(row, "MAX") ?: getDouble(row, "MAX_LIMIT") ?: 100.0 + val attrCode = getNumber(row, "Attr")?.toInt() ?: getNumber(row, "ATTR")?.toInt() ?: 0 + val attrType = MdbAttrType.fromCode(attrCode) + val description = getString(row, "DESCRIPTION") ?: "" + + constants.add( + MdbConstant( + idx = idx, + name = name, + data = data, + addr = addr, + min = min, + max = max, + attr = attrType, + description = description + ) + ) + } + + return constants + } + + /** + * Tb1DmPxDyLst (Type A) 및 Tb1DmPyDxLst (Type B) 파싱 및 [YNAME] 동적 포인트 테이블 결합 + */ + private fun parseCompensationMaps(db: Database): List { + val maps = mutableListOf() + + // 1. Type A (Tb1DmPxDyLst) + val tbPxDy = db.getTable("Tb1DmPxDyLst") + if (tbPxDy != null) { + for (row in tbPxDy) { + val idx = getNumber(row, "IDX")?.toInt() ?: maps.size + val yName = getString(row, "YNAME") ?: continue + val yAddr = getNumber(row, "YADDR")?.toLong() ?: 0L + val yAttrCode = getNumber(row, "YATTR")?.toInt() ?: 2 + val yMin = getDouble(row, "YMIN") ?: 0.0 + val yMax = getDouble(row, "YMAX") ?: 100.0 + val xName = getString(row, "XNAME") ?: "" + val xAddr = getNumber(row, "XADDR")?.toLong() ?: 0L + val xAttrCode = getNumber(row, "XATTR")?.toInt() ?: 2 + val cvsA = getDouble(row, "CVS_A") ?: 1.0 + val cvsB = getDouble(row, "CVS_B") ?: 0.0 + val desc = getString(row, "DESCRIPTION") ?: "" + + val points = parsePointsTable(db, yName) + + maps.add( + CompensationMap( + type = CompensationMapType.TYPE_A, + idx = idx, + yName = yName, + yAddr = yAddr, + yAttr = MdbAttrType.fromCode(yAttrCode), + yMin = yMin, + yMax = yMax, + xName = xName, + xAddr = xAddr, + xAttr = MdbAttrType.fromCode(xAttrCode), + cvsA = cvsA, + cvsB = cvsB, + description = desc, + points = points + ) + ) + } + } + + // 2. Type B (Tb1DmPyDxLst) + val tbPyDx = db.getTable("Tb1DmPyDxLst") + if (tbPyDx != null) { + for (row in tbPyDx) { + val idx = getNumber(row, "IDX")?.toInt() ?: maps.size + val yName = getString(row, "YNAME") ?: continue + val yAddr = getNumber(row, "YADDR")?.toLong() ?: 0L + val yAttrCode = getNumber(row, "YATTR")?.toInt() ?: 0 + val yMin = getDouble(row, "YMIN") ?: 0.0 + val yMax = getDouble(row, "YMAX") ?: 100.0 + val cvsA = getDouble(row, "CVS_A") ?: 1.0 + val cvsB = getDouble(row, "CVS_B") ?: 0.0 + val xName = getString(row, "XNAME") ?: "" + val xAddr = getNumber(row, "XADDR")?.toLong() ?: 0L + val xAttrCode = getNumber(row, "XATTR")?.toInt() ?: 2 + val desc = getString(row, "DESCRIPTION") ?: "" + + val points = parsePointsTable(db, yName) + + maps.add( + CompensationMap( + type = CompensationMapType.TYPE_B, + idx = idx, + yName = yName, + yAddr = yAddr, + yAttr = MdbAttrType.fromCode(yAttrCode), + yMin = yMin, + yMax = yMax, + xName = xName, + xAddr = xAddr, + xAttr = MdbAttrType.fromCode(xAttrCode), + cvsA = cvsA, + cvsB = cvsB, + description = desc, + points = points + ) + ) + } + } + + return maps + } + + /** + * 개별 [YNAME] 동적 포인트 테이블 파싱 (IDX vs ID 컬럼 호환) + */ + private fun parsePointsTable(db: Database, tableName: String): List { + val table = db.getTable(tableName) ?: return emptyList() + val points = mutableListOf() + + for (row in table) { + val idx = getNumber(row, "IDX")?.toInt() + ?: getNumber(row, "ID")?.toInt() + ?: getNumber(row, "INDEX")?.toInt() + ?: points.size + + val bkp = getNumber(row, "BKP")?.toInt() + ?: getNumber(row, "ENG_RPM")?.toInt() + ?: 0 + + val rawData = getNumber(row, "DATA")?.toInt() + ?: getNumber(row, "RAW_DATA")?.toInt() + ?: 0 + + val phy = getDouble(row, "PHY") + ?: getDouble(row, "PHY_VALUE") + ?: getDouble(row, "CORRECTION_FACTOR") + ?: 0.0 + + points.add( + MapPoint( + idx = idx, + bkp = bkp, + data = rawData, + phy = phy + ) + ) + } + + return points.sortedBy { it.idx } + } + + // Helper functions for safe case-insensitive column reading + private fun findValue(row: Row, columnName: String): Any? { + // First try exact match + if (row.containsKey(columnName)) { + return row[columnName] + } + // Then try case-insensitive + for (key in row.keys) { + if (key.equals(columnName, ignoreCase = true)) { + return row[key] + } + } + return null + } + + private fun getString(row: Row, columnName: String): String? { + val v = findValue(row, columnName) ?: return null + return v.toString() + } + + private fun getNumber(row: Row, columnName: String): Number? { + val v = findValue(row, columnName) ?: return null + if (v is Number) return v + return v.toString().toDoubleOrNull() + } + + private fun getDouble(row: Row, columnName: String): Double? { + val v = findValue(row, columnName) ?: return null + if (v is Number) return v.toDouble() + return v.toString().toDoubleOrNull() + } + + private fun getBoolean(row: Row, columnName: String): Boolean? { + val v = findValue(row, columnName) ?: return null + if (v is Boolean) return v + val str = v.toString() + return str.equals("true", ignoreCase = true) || str == "1" || str.equals("Y", ignoreCase = true) + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/mdb/VirtualMdbGenerator.kt b/Android/app/src/main/java/com/dct/app/data/mdb/VirtualMdbGenerator.kt new file mode 100644 index 0000000..f474d32 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/mdb/VirtualMdbGenerator.kt @@ -0,0 +1,208 @@ +package com.dct.app.data.mdb + +import com.healthmarketscience.jackcess.* +import java.io.File +import java.io.OutputStream +import java.nio.charset.Charset +import java.util.Date + +/** + * 테스트 및 표준 샘플 MDB 데이터베이스 생성기 + * + * C++ PC 소프트웨어(refer/dct_sw_xcp_pc)의 표준 스키마 100% 호환: + * 1. VarNif (신호 그룹) + * 2. VarLst (실시간 계측 신호) + * 3. ConLst (캘리브레이션 상수) + * 4. Tb1DmPxDyLst (1D 보정 맵 Type A) + * 5. Tb1DmPyDxLst (1D 보정 맵 Type B) + * 6. 동적 [YNAME] 포인트 테이블 (MAP_COOLANT_TEMP_COMP, CURVE_BLOWER_PWM) + * 7. 하위 호환성 테이블 (__TB_TQ_OFFSET_A20_PN, EngTempALL) + */ +object VirtualMdbGenerator { + + val MS949: Charset = Charset.forName("MS949") + + /** + * 지정된 파일 위치에 표준 V2000 MDB 데이터베이스 생성 + */ + fun createStandardDatabase(targetFile: File): Database { + if (targetFile.exists()) { + targetFile.delete() + } + + val db = DatabaseBuilder.create(Database.FileFormat.V2000, targetFile) + + // 1. VarNif (신호 그룹 메타데이터 테이블) + val tbVarNif = TableBuilder("VarNif") + .addColumn(ColumnBuilder("RECLID", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("IDX", DataType.TEXT).setLength(50).toColumn()) + .addColumn(ColumnBuilder("DESCRIP", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("USAGE", DataType.BOOLEAN).toColumn()) + .toTable(db) + + tbVarNif.addRow(1L, "0", "Engine_Sensors", true) + tbVarNif.addRow(2L, "1", "Transmission_Sensors", true) + tbVarNif.addRow(3L, "2", "Climate_Control", true) + + // 2. VarLst (개별 신호 정의 테이블) + val tbVarLst = TableBuilder("VarLst") + .addColumn(ColumnBuilder("RECLID", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("ADL", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("NAME", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("MIN", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("MAX", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("UNIT", DataType.TEXT).setLength(50).toColumn()) + .addColumn(ColumnBuilder("COLOR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("CVS_A", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("CVS_B", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("CVS_T", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("ATTR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("DESCRIPTION", DataType.TEXT).setLength(255).toColumn()) + .toTable(db) + + // RECLID 1: Engine Signals (Total size: 4 + 2 + 1 + 2 = 9 bytes) + tbVarLst.addRow(1L, 0L, 0L, "EngineRPM", 0.0, 8000.0, "rpm", 0xFF0000L, 1.0, 0.0, "", 4L, "엔진 회전수") + tbVarLst.addRow(1L, 1L, 0L, "CoolantTemp", -40.0, 150.0, "°C", 0x00FF00L, 0.1, -40.0, "", 2L, "냉각수 온도") + tbVarLst.addRow(1L, 2L, 0L, "ThrottlePos", 0.0, 100.0, "%", 0x0000FFL, 0.5, 0.0, "", 0L, "스로틀 개도율") + tbVarLst.addRow(1L, 3L, 0L, "IntakePressure", 0.0, 250.0, "kPa", 0xFFFF00L, 0.1, 0.0, "", 2L, "흡기 매니폴드 압력") + + // RECLID 2: Transmission Signals (Total size: 2 + 2 + 1 = 5 bytes) + tbVarLst.addRow(2L, 0L, 0L, "VehicleSpeed", 0.0, 250.0, "km/h", 0x00FFFFL, 0.1, 0.0, "", 2L, "차량 속도") + tbVarLst.addRow(2L, 1L, 0L, "ClutchPressure", 0.0, 50.0, "bar", 0xFF00FFL, 0.05, 0.0, "", 2L, "클러치 유압") + tbVarLst.addRow(2L, 2L, 0L, "GearPosition", 0.0, 8.0, "gear", 0xAAAAAAL, 1.0, 0.0, "", 1L, "현재 기어 단수") + + // RECLID 3: Climate Signals (Total size: 2 + 2 + 2 = 6 bytes) + tbVarLst.addRow(3L, 0L, 0L, "IncarTemp", -20.0, 60.0, "°C", 0x112233L, 0.1, -20.0, "", 2L, "실내 온도") + tbVarLst.addRow(3L, 1L, 0L, "AmbientTemp", -40.0, 80.0, "°C", 0x445566L, 0.1, -40.0, "", 2L, "외기 온도") + tbVarLst.addRow(3L, 2L, 0L, "SolarSensor", 0.0, 1200.0, "W/m²", 0x778899L, 1.0, 0.0, "", 2L, "일사량 센서") + + // 3. ConLst (상수 제어 테이블) + val tbConLst = TableBuilder("ConLst") + .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("NAME", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("DATA", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("ADDR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("MIN", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("MAX", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("Attr", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("DESCRIPTION", DataType.TEXT).setLength(255).toColumn()) + .toTable(db) + + tbConLst.addRow(0L, "TCRmin", 10.0, 0x1000L, 0.0, 100.0, 2L, "최소 트랙션 제어 비율") + tbConLst.addRow(1L, "Duct_Vent", 1.0, 0x1004L, 0.0, 1.0, 0L, "덕트 벤트 개폐 제어") + tbConLst.addRow(2L, "Target_RPM", 850.0, 0x1008L, 500.0, 6000.0, 4L, "목표 아이들 RPM") + tbConLst.addRow(3L, "Fan_Speed_High", 95.0, 0x100CL, 0.0, 100.0, 0L, "고속 팬 구동 온도 임계치") + + // 4. Tb1DmPxDyLst (1D 보정 맵 Type A 메타데이터) + val tb1DmPxDy = TableBuilder("Tb1DmPxDyLst") + .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("YNAME", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("YADDR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("YATTR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("YMIN", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("YMAX", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("XNAME", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("XADDR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("XATTR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("CVS_A", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("CVS_B", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("DESCRIPTION", DataType.TEXT).setLength(255).toColumn()) + .toTable(db) + + tb1DmPxDy.addRow(0L, "MAP_COOLANT_TEMP_COMP", 0x2000L, 2L, -40.0, 150.0, "ENGINE_SPEED", 0x2100L, 4L, 1.0, 0.0, "냉각수 온도 보정 맵") + + // 5. [MAP_COOLANT_TEMP_COMP] 동적 포인트 테이블 + val tbCoolantComp = TableBuilder("MAP_COOLANT_TEMP_COMP") + .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("BKP", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("DATA", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("PHY", DataType.DOUBLE).toColumn()) + .toTable(db) + + tbCoolantComp.addRow(0L, 500L, 120L, 1.20) + tbCoolantComp.addRow(1L, 1000L, 105L, 1.05) + tbCoolantComp.addRow(2L, 2000L, 100L, 1.00) + tbCoolantComp.addRow(3L, 3000L, 95L, 0.95) + tbCoolantComp.addRow(4L, 4000L, 90L, 0.90) + + // 6. Tb1DmPyDxLst (1D 보정 맵 Type B 메타데이터) + val tb1DmPyDx = TableBuilder("Tb1DmPyDxLst") + .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("YNAME", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("YADDR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("YATTR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("YMIN", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("YMAX", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("CVS_A", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("CVS_B", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("XNAME", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("XADDR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("XATTR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("DESCRIPTION", DataType.TEXT).setLength(255).toColumn()) + .toTable(db) + + tb1DmPyDx.addRow(0L, "CURVE_BLOWER_PWM", 0x3000L, 0L, 0.0, 100.0, 1.0, 0.0, "CABIN_TEMP_DIFF", 0x3100L, 2L, "블로워 모터 PWM 출력 커브") + + // 7. [CURVE_BLOWER_PWM] 동적 포인트 테이블 + val tbBlowerPwm = TableBuilder("CURVE_BLOWER_PWM") + .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("BKP", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("DATA", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("PHY", DataType.DOUBLE).toColumn()) + .toTable(db) + + tbBlowerPwm.addRow(0L, 0L, 0L, 0.0) + tbBlowerPwm.addRow(1L, 5L, 30L, 30.0) + tbBlowerPwm.addRow(2L, 10L, 60L, 60.0) + tbBlowerPwm.addRow(3L, 15L, 100L, 100.0) + + // 8. 하위 호환용 테이블 (__TB_TQ_OFFSET_A20_PN, EngTempALL) + val tbTq = TableBuilder("__TB_TQ_OFFSET_A20_PN") + .addColumn(ColumnBuilder("PARAM_ID", DataType.LONG).setAutoNumber(true)) + .addColumn(ColumnBuilder("PARAM_NAME", DataType.TEXT).setLength(50)) + .addColumn(ColumnBuilder("OFFSET_VAL", DataType.DOUBLE)) + .addColumn(ColumnBuilder("UNIT", DataType.TEXT).setLength(20)) + .addColumn(ColumnBuilder("UPDATED_AT", DataType.SHORT_DATE_TIME)) + .toTable(db) + + val now = Date() + tbTq.addRow(Column.AUTO_NUMBER, "TQ_OFFSET_LOW", 12.5, "Nm", now) + tbTq.addRow(Column.AUTO_NUMBER, "TQ_OFFSET_MID", 24.8, "Nm", now) + tbTq.addRow(Column.AUTO_NUMBER, "TQ_OFFSET_HIGH", 55.0, "Nm", now) + + val tbTemp = TableBuilder("EngTempALL") + .addColumn(ColumnBuilder("INDEX", DataType.LONG).setAutoNumber(true)) + .addColumn(ColumnBuilder("ENG_RPM", DataType.INT)) + .addColumn(ColumnBuilder("COOLANT_TEMP", DataType.DOUBLE)) + .addColumn(ColumnBuilder("CORRECTION_FACTOR", DataType.FLOAT)) + .toTable(db) + + tbTemp.addRow(Column.AUTO_NUMBER, 1000.toShort(), 25.0, 1.05f) + tbTemp.addRow(Column.AUTO_NUMBER, 2000.toShort(), 80.0, 1.00f) + tbTemp.addRow(Column.AUTO_NUMBER, 3000.toShort(), 95.0, 0.98f) + + db.flush() + return db + } + + /** + * OutputStream으로 표준 MDB 바이너리 출력 + */ + fun createStandardDatabase(outputStream: OutputStream) { + val tempFile = File.createTempFile("standard_sample_", ".mdb") + try { + val db = createStandardDatabase(tempFile) + db.close() + + tempFile.inputStream().use { input -> + input.copyTo(outputStream) + } + outputStream.flush() + } finally { + if (tempFile.exists()) { + tempFile.delete() + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt b/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt new file mode 100644 index 0000000..0d15412 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt @@ -0,0 +1,823 @@ +package com.dct.app.data.parser + +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import java.io.InputStream +import kotlin.random.Random + +/** + * ASAM MCD-2MC / ASAP2 A2L 파일 파서 + * PC 버전(a2l/src, xcp_item.cpp) 호환 + * - IFDATA XCP_ON_CAN (CAN Master/Slave ID, Baudrate, Byte Order) + * - COMPU_METHOD (LINEAR, RAT_FUNC, IDENTICAL, TAB_INTP, TAB_VERB, COMPU_VTAB) + * - MEASUREMENT (Name, Description, DataType, ECU_ADDRESS, MATRIX_DIM, LowerLimit, UpperLimit, Format, Unit) + * - CHARACTERISTIC (Type, ECU_ADDRESS, RecordLayout, Limits) + */ +object A2lParser { + + /** + * XCP on CAN 전송 계층 설정 구조체 + */ + data class XcpIfData( + val masterCanId: Long = 0x7E0L, + val slaveCanId: Long = 0x7E8L, + val baudrate: Int = 500000, + val isMsbFirst: Boolean = false, + val samplePoint: Int = 80 + ) + + /** + * A2L 변환 공식 구조체 + */ + data class CompuMethod( + val name: String, + val description: String = "", + val conversionType: String = "IDENTICAL", + val format: String = "%6.3f", + val unit: String = "", + val cvsA: Double = 1.0, + val cvsB: Double = 0.0, + val cvsC: Double = 0.0, + val cvsD: Double = 0.0, + val cvsE: Double = 0.0, + val cvsF: Double = 1.0, + val vtabMap: Map = emptyMap(), + val tabIntpPoints: List> = emptyList() + ) { + /** + * Raw 값으로부터 물리값 계산 (Rational function, Linear, Tab Intp 지원) + */ + fun calculatePhysicalValue(raw: Double): Double { + return when (conversionType.uppercase()) { + "IDENTICAL" -> raw + "LINEAR" -> raw * cvsA + cvsB + "RAT_FUNC" -> { + val num = (cvsA * raw * raw) + (cvsB * raw) + cvsC + val denom = (cvsD * raw * raw) + (cvsE * raw) + cvsF + if (denom != 0.0) num / denom else raw + } + + "TAB_INTP" -> { + if (tabIntpPoints.isEmpty()) return raw + if (raw <= tabIntpPoints.first().first) return tabIntpPoints.first().second + if (raw >= tabIntpPoints.last().first) return tabIntpPoints.last().second + for (i in 0 until tabIntpPoints.size - 1) { + val p0 = tabIntpPoints[i] + val p1 = tabIntpPoints[i + 1] + if (raw >= p0.first && raw <= p1.first) { + val ratio = (raw - p0.first) / (p1.first - p0.first) + return p0.second + ratio * (p1.second - p0.second) + } + } + raw + } + + else -> raw * cvsA + cvsB + } + } + + fun getVerbalString(raw: Double): String? { + return vtabMap[raw] + } + } + + /** + * A2L 원본 계측 항목 (Measurements) + */ + data class A2lMeasurement( + val name: String, + val description: String = "", + val dataType: String = "UBYTE", + val a2lDataType: A2lDataType = A2lDataType.UBYTE, + val conversionMethod: String = "", + val resolution: Int = 1, + val accuracy: Double = 0.0, + val lowerLimit: Double = 0.0, + val upperLimit: Double = 100.0, + val ecuAddress: Long = 0L, + val format: String = "%6.3f", + val unit: String = "", + val matrixDim: List = emptyList() + ) + + /** + * A2L 단일 특성치 (Characteristics - VALUE, ASCII 등) + */ + data class A2lCharacteristic( + val name: String, + val description: String = "", + val type: String = "VALUE", + val ecuAddress: Long = 0L, + val deposit: String = "", + val maxDiff: Double = 0.0, + val conversionMethod: String = "", + val lowerLimit: Double = 0.0, + val upperLimit: Double = 100.0, + var value: Double = 0.0, + val unit: String = "", + val format: String = "%6.3f", + val dataType: String = "UBYTE", + val a2lDataType: A2lDataType = A2lDataType.UBYTE, + val attr: MdbAttrType = MdbAttrType.ATTR_0_UINT8 + ) + + /** + * A2L 다차원 배열 특성치 (CharacteristicsArray - VAL_BLK, CURVE, MAP) + */ + data class A2lCharacteristicArray( + val name: String, + val description: String = "", + val type: String = "VAL_BLK", + val ecuAddress: Long = 0L, + val deposit: String = "", + val matrixDim: List = listOf(1), + var values: List = emptyList(), + val unit: String = "", + val format: String = "%6.3f", + val dataType: String = "UBYTE", + val a2lDataType: A2lDataType = A2lDataType.UBYTE, + val lowerLimit: Double = 0.0, + val upperLimit: Double = 100.0, + val attr: MdbAttrType = MdbAttrType.ATTR_0_UINT8 + ) + + /** + * A2L 전체 파싱 결과 통합 모델 + */ + data class A2lParseResult( + val measurements: List = emptyList(), + val rawMeasurements: List = emptyList(), + val characteristics: List = emptyList(), + val characteristicsArray: List = emptyList(), + val ifData: XcpIfData? = null, + val compuMethods: Map = emptyMap() + ) + + // 정규식 정의 + private val COMPU_METHOD_START_REGEX = Regex("""/begin\s+COMPU_METHOD\s+(\w+)""", RegexOption.IGNORE_CASE) + private val COMPU_VTAB_START_REGEX = Regex("""/begin\s+COMPU_VTAB\s+(\w+)""", RegexOption.IGNORE_CASE) + private val COMPU_TAB_START_REGEX = Regex("""/begin\s+COMPU_TAB\s+(\w+)""", RegexOption.IGNORE_CASE) + private val MEASUREMENT_START_REGEX = Regex("""/begin\s+MEASUREMENT\s+(\w+)""", RegexOption.IGNORE_CASE) + private val CHARACTERISTIC_START_REGEX = Regex("""/begin\s+CHARACTERISTIC\s+(\w+)""", RegexOption.IGNORE_CASE) + private val IF_DATA_START_REGEX = Regex("""/begin\s+IF_DATA\s+(\w+)""", RegexOption.IGNORE_CASE) + private val ECU_ADDRESS_REGEX = Regex("""ECU_ADDRESS\s+(0x[0-9a-fA-F]+|\d+)""", RegexOption.IGNORE_CASE) + private val FORMAT_REGEX = Regex("""FORMAT\s+"([^"]+)"""", RegexOption.IGNORE_CASE) + private val UNIT_REGEX = Regex("""UNIT\s+"([^"]+)"""", RegexOption.IGNORE_CASE) + private val MATRIX_DIM_REGEX = Regex("""MATRIX_DIM\s+([\d\s]+)""", RegexOption.IGNORE_CASE) + private val NUMBER_REGEX = Regex("""NUMBER\s+(\d+)""", RegexOption.IGNORE_CASE) + private val DEFAULT_VALUE_REGEX = Regex("""DEFAULT_VALUE(?:_NUMERIC)?\s+([^\s]+)""", RegexOption.IGNORE_CASE) + private val COEFFS_LINEAR_REGEX = Regex("""COEFFS_LINEAR\s+([^\s]+)\s+([^\s]+)""", RegexOption.IGNORE_CASE) + private val COEFFS_REGEX = + Regex("""COEFFS\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)""", RegexOption.IGNORE_CASE) + private val CAN_ID_MASTER_REGEX = Regex("""CAN_ID_MASTER\s+(0x[0-9a-fA-F]+|\d+)""", RegexOption.IGNORE_CASE) + private val CAN_ID_SLAVE_REGEX = Regex("""CAN_ID_SLAVE\s+(0x[0-9a-fA-F]+|\d+)""", RegexOption.IGNORE_CASE) + private val BAUDRATE_REGEX = Regex("""BAUDRATE\s+(\d+)""", RegexOption.IGNORE_CASE) + private val BYTE_ORDER_REGEX = Regex("""BYTE_ORDER\s+(\w+)""", RegexOption.IGNORE_CASE) + + fun parseA2l(inputStream: InputStream): List { + return parseA2lFull(inputStream).measurements + } + + fun parseA2lFull(inputStream: InputStream): A2lParseResult { + val lines = com.dct.app.core.util.TextStreamDecoder.readLinesSmart(inputStream) + + val compuMethods = mutableMapOf() + val vtabs = mutableMapOf>() + val compuTabs = mutableMapOf>>() + val measurements = mutableListOf() + val rawCharacteristics = mutableListOf() + var parsedIfData: XcpIfData? = null + + var i = 0 + while (i < lines.size) { + val line = lines[i].trim() + + // 1. IF_DATA 파싱 (XCP on CAN) + val ifMatch = IF_DATA_START_REGEX.find(line) + if (ifMatch != null) { + val ifType = ifMatch.groupValues[1] + val blockBuilder = StringBuilder(line).append("\n") + i++ + while (i < lines.size) { + val subLine = lines[i].trim() + blockBuilder.append(subLine).append("\n") + if (subLine.startsWith("/end IF_DATA", ignoreCase = true)) break + i++ + } + if (ifType.contains("XCP", ignoreCase = true)) { + parsedIfData = parseIfDataBlock(blockBuilder.toString()) + } + i++ + continue + } + + // 2. COMPU_VTAB 파싱 + val vtabMatch = COMPU_VTAB_START_REGEX.find(line) + if (vtabMatch != null) { + val tabName = vtabMatch.groupValues[1] + val blockBuilder = StringBuilder(line).append("\n") + i++ + while (i < lines.size) { + val subLine = lines[i].trim() + blockBuilder.append(subLine).append("\n") + if (subLine.startsWith("/end COMPU_VTAB", ignoreCase = true)) break + i++ + } + vtabs[tabName.lowercase()] = parseCompuVTabBlock(blockBuilder.toString()) + i++ + continue + } + + // 3. COMPU_TAB 파싱 + val tabMatch = COMPU_TAB_START_REGEX.find(line) + if (tabMatch != null) { + val tabName = tabMatch.groupValues[1] + val blockBuilder = StringBuilder(line).append("\n") + i++ + while (i < lines.size) { + val subLine = lines[i].trim() + blockBuilder.append(subLine).append("\n") + if (subLine.startsWith("/end COMPU_TAB", ignoreCase = true)) break + i++ + } + compuTabs[tabName.lowercase()] = parseCompuTabBlock(blockBuilder.toString()) + i++ + continue + } + + // 4. COMPU_METHOD 파싱 + val compuMatch = COMPU_METHOD_START_REGEX.find(line) + if (compuMatch != null) { + val methodName = compuMatch.groupValues[1] + val blockBuilder = StringBuilder(line).append("\n") + i++ + while (i < lines.size) { + val subLine = lines[i].trim() + blockBuilder.append(subLine).append("\n") + if (subLine.startsWith("/end COMPU_METHOD", ignoreCase = true)) break + i++ + } + val compuMethod = parseCompuMethodBlock(methodName, blockBuilder.toString(), vtabs, compuTabs) + compuMethods[methodName.lowercase()] = compuMethod + i++ + continue + } + + // 5. MEASUREMENT 파싱 + val measMatch = MEASUREMENT_START_REGEX.find(line) + if (measMatch != null) { + val measName = measMatch.groupValues[1] + val blockBuilder = StringBuilder(line).append("\n") + i++ + while (i < lines.size) { + val subLine = lines[i].trim() + blockBuilder.append(subLine).append("\n") + if (subLine.startsWith("/end MEASUREMENT", ignoreCase = true)) break + i++ + } + val measurement = parseMeasurementBlock(measName, blockBuilder.toString(), compuMethods) + measurements.add(measurement) + i++ + continue + } + + // 6. CHARACTERISTIC 파싱 + val charMatch = CHARACTERISTIC_START_REGEX.find(line) + if (charMatch != null) { + val charName = charMatch.groupValues[1] + val blockBuilder = StringBuilder(line).append("\n") + i++ + while (i < lines.size) { + val subLine = lines[i].trim() + blockBuilder.append(subLine).append("\n") + if (subLine.startsWith("/end CHARACTERISTIC", ignoreCase = true)) break + i++ + } + val rawChar = parseCharacteristicBlock(charName, blockBuilder.toString()) + rawCharacteristics.add(rawChar) + i++ + continue + } + + i++ + } + + // 7. A2lMeasurement -> SignalDefinition 변환 및 MATRIX_DIM 배열 확장 + val signals = mutableListOf() + var signalIndex = 0 + + for (meas in measurements) { + val compu = compuMethods[meas.conversionMethod.lowercase()] + val rawUnit = if (meas.unit.isNotBlank()) meas.unit else compu?.unit ?: "" + val unit = com.dct.app.core.util.TextStreamDecoder.sanitizeUnit(rawUnit) + val factor = compu?.cvsA ?: 1.0 + val offset = compu?.cvsB ?: 0.0 + + val attr = meas.a2lDataType.toMdbAttrType() + val byteSize = meas.a2lDataType.byteSize + + val totalElements = if (meas.matrixDim.isNotEmpty()) { + meas.matrixDim.reduce { acc, d -> acc * d } + } else { + 1 + } + + if (totalElements > 1) { + for (dimIdx in 0 until totalElements) { + val elemName = "${meas.name}[$dimIdx]" + val elemAddr = meas.ecuAddress + (dimIdx * byteSize) + + val seed = elemName.hashCode().toLong() xor (elemAddr shl 16) + val rnd = Random(seed) + val r = rnd.nextInt(256) + val g = rnd.nextInt(256) + val b = rnd.nextInt(256) + val color = (r.toLong() or (g.toLong() shl 8) or (b.toLong() shl 16)) + + signals.add( + SignalDefinition( + reclid = 0, + idx = signalIndex++, + name = elemName, + attr = attr, + cvsA = factor, + cvsB = offset, + unit = unit, + color = color, + min = meas.lowerLimit, + max = meas.upperLimit, + description = meas.description, + adl = elemAddr.toInt(), + arrayIndex = dimIdx, + messageName = "A2L", + messageId = elemAddr, + a2lDataType = meas.a2lDataType, + measurementFormat = meas.format + ) + ) + } + } else { + val seed = meas.name.hashCode().toLong() xor (meas.ecuAddress shl 16) + val rnd = Random(seed) + val r = rnd.nextInt(256) + val g = rnd.nextInt(256) + val b = rnd.nextInt(256) + val color = (r.toLong() or (g.toLong() shl 8) or (b.toLong() shl 16)) + + signals.add( + SignalDefinition( + reclid = 0, + idx = signalIndex++, + name = meas.name, + attr = attr, + cvsA = factor, + cvsB = offset, + unit = unit, + color = color, + min = meas.lowerLimit, + max = meas.upperLimit, + description = meas.description, + adl = meas.ecuAddress.toInt(), + arrayIndex = 0, + messageName = "A2L", + messageId = meas.ecuAddress, + a2lDataType = meas.a2lDataType, + measurementFormat = meas.format + ) + ) + } + } + signals.sortBy { it.adl.toLong() and 0xFFFFFFFFL } + + // 8. RawCharacteristic 분류 (Characteristics vs CharacteristicsArray) + val characteristics = mutableListOf() + val characteristicsArray = mutableListOf() + + for (raw in rawCharacteristics) { + val compu = compuMethods[raw.conversionMethod.lowercase()] + val rawUnit = if (raw.unit.isNotBlank()) raw.unit else compu?.unit ?: "" + val unit = com.dct.app.core.util.TextStreamDecoder.sanitizeUnit(rawUnit) + val a2lType = A2lDataType.fromString(raw.dataType) + val attr = a2lType.toMdbAttrType() + + val isArray = + raw.matrixDim.isNotEmpty() || raw.type in setOf("VAL_BLK", "CURVE", "MAP", "CUBOID", "CUBE_4", "CUBE_5") + + if (isArray) { + val totalCount = if (raw.matrixDim.isNotEmpty()) raw.matrixDim.reduce { acc, d -> acc * d } else 1 + val initialValues = if (raw.values.isNotEmpty()) { + raw.values.take(totalCount) + } else { + List(totalCount) { 0.0 } + } + characteristicsArray.add( + A2lCharacteristicArray( + name = raw.name, + description = raw.description, + type = raw.type, + ecuAddress = raw.ecuAddress, + deposit = raw.deposit, + matrixDim = if (raw.matrixDim.isNotEmpty()) raw.matrixDim else listOf(1), + values = initialValues, + unit = unit, + format = raw.format, + dataType = raw.dataType, + a2lDataType = a2lType, + lowerLimit = raw.lowerLimit, + upperLimit = raw.upperLimit, + attr = attr + ) + ) + } else { + characteristics.add( + A2lCharacteristic( + name = raw.name, + description = raw.description, + type = raw.type, + ecuAddress = raw.ecuAddress, + deposit = raw.deposit, + maxDiff = raw.maxDiff, + conversionMethod = raw.conversionMethod, + lowerLimit = raw.lowerLimit, + upperLimit = raw.upperLimit, + value = if (raw.values.isNotEmpty()) raw.values[0] else 0.0, + unit = unit, + format = raw.format, + dataType = raw.dataType, + a2lDataType = a2lType, + attr = attr + ) + ) + } + } + characteristics.sortBy { it.ecuAddress } + characteristicsArray.sortBy { it.ecuAddress } + + return A2lParseResult( + measurements = signals, + rawMeasurements = measurements, + characteristics = characteristics, + characteristicsArray = characteristicsArray, + ifData = parsedIfData, + compuMethods = compuMethods + ) + } + + private data class RawCharacteristic( + val name: String, + val description: String = "", + val type: String = "VALUE", + val ecuAddress: Long = 0L, + val deposit: String = "", + val maxDiff: Double = 0.0, + val conversionMethod: String = "", + val lowerLimit: Double = 0.0, + val upperLimit: Double = 100.0, + val format: String = "%6.3f", + val unit: String = "", + val matrixDim: List = emptyList(), + val values: List = emptyList(), + val dataType: String = "UBYTE" + ) + + private fun cleanA2lComments(text: String): String { + val withoutBlockComments = text.replace(Regex("""/\*[\s\S]*?\*/"""), " ") + return withoutBlockComments.replace(Regex("""//.*"""), " ") + } + + private fun parseIfDataBlock(block: String): XcpIfData { + val cleaned = cleanA2lComments(block) + var masterId = 0x7E0L + var slaveId = 0x7E8L + var baud = 500000 + var isMsbFirst = false + + CAN_ID_MASTER_REGEX.find(cleaned)?.let { m -> + val s = m.groupValues[1] + masterId = if (s.startsWith("0x", true)) s.substring(2).toLongOrNull(16) ?: masterId else s.toLongOrNull() + ?: masterId + } + CAN_ID_SLAVE_REGEX.find(cleaned)?.let { m -> + val s = m.groupValues[1] + slaveId = if (s.startsWith("0x", true)) s.substring(2).toLongOrNull(16) ?: slaveId else s.toLongOrNull() + ?: slaveId + } + BAUDRATE_REGEX.find(cleaned)?.let { m -> + baud = m.groupValues[1].toIntOrNull() ?: baud + } + BYTE_ORDER_REGEX.find(cleaned)?.let { m -> + isMsbFirst = m.groupValues[1].equals("MSB_FIRST", ignoreCase = true) + } + + return XcpIfData( + masterCanId = masterId, + slaveCanId = slaveId, + baudrate = baud, + isMsbFirst = isMsbFirst + ) + } + + private fun parseCompuVTabBlock(block: String): Map { + val cleaned = cleanA2lComments(block) + val tokens = tokenizeA2lBlock(cleaned) + val map = mutableMapOf() + var i = 0 + while (i < tokens.size - 1) { + val d = tokens[i].toDoubleOrNull() + if (d != null) { + val str = tokens[i + 1] + map[d] = str + i += 2 + } else { + i++ + } + } + return map + } + + private fun parseCompuTabBlock(block: String): List> { + val cleaned = cleanA2lComments(block) + val tokens = tokenizeA2lBlock(cleaned) + val points = mutableListOf>() + var i = 0 + while (i < tokens.size - 1) { + val inVal = tokens[i].toDoubleOrNull() + val outVal = tokens[i + 1].toDoubleOrNull() + if (inVal != null && outVal != null) { + points.add(inVal to outVal) + i += 2 + } else { + i++ + } + } + points.sortBy { it.first } + return points + } + + private fun parseCompuMethodBlock( + methodName: String, + block: String, + vtabs: Map>, + compuTabs: Map>> + ): CompuMethod { + val cleanedBlock = cleanA2lComments(block) + val tokens = tokenizeA2lBlock(cleanedBlock) + var description = "" + var convType = "IDENTICAL" + var format = "%6.3f" + var unit = "" + var cvsA = 1.0 + var cvsB = 0.0 + var cvsC = 0.0 + var cvsD = 0.0 + var cvsE = 0.0 + var cvsF = 1.0 + var refTabName = "" + + val nameIdx = tokens.indexOfFirst { it.equals(methodName, ignoreCase = true) } + if (nameIdx != -1) { + description = tokens.getOrNull(nameIdx + 1) ?: "" + convType = tokens.getOrNull(nameIdx + 2) ?: "IDENTICAL" + format = tokens.getOrNull(nameIdx + 3) ?: "%6.3f" + unit = tokens.getOrNull(nameIdx + 4) ?: "" + refTabName = tokens.getOrNull(nameIdx + 5) ?: "" + } + + val linearMatch = COEFFS_LINEAR_REGEX.find(cleanedBlock) + if (linearMatch != null) { + cvsA = linearMatch.groupValues[1].toDoubleOrNull() ?: 1.0 + cvsB = linearMatch.groupValues[2].toDoubleOrNull() ?: 0.0 + } + + val coeffsMatch = COEFFS_REGEX.find(cleanedBlock) + if (coeffsMatch != null) { + cvsA = coeffsMatch.groupValues[1].toDoubleOrNull() ?: 1.0 + cvsB = coeffsMatch.groupValues[2].toDoubleOrNull() ?: 0.0 + cvsC = coeffsMatch.groupValues[3].toDoubleOrNull() ?: 0.0 + cvsD = coeffsMatch.groupValues[4].toDoubleOrNull() ?: 0.0 + cvsE = coeffsMatch.groupValues[5].toDoubleOrNull() ?: 0.0 + cvsF = coeffsMatch.groupValues[6].toDoubleOrNull() ?: 1.0 + } + + val vtab = if (refTabName.isNotBlank()) vtabs[refTabName.lowercase()] ?: emptyMap() else emptyMap() + val ctab = if (refTabName.isNotBlank()) compuTabs[refTabName.lowercase()] ?: emptyList() else emptyList() + + return CompuMethod( + name = methodName, + description = description, + conversionType = convType, + format = format, + unit = unit, + cvsA = cvsA, + cvsB = cvsB, + cvsC = cvsC, + cvsD = cvsD, + cvsE = cvsE, + cvsF = cvsF, + vtabMap = vtab, + tabIntpPoints = ctab + ) + } + + private fun parseMeasurementBlock( + measName: String, + block: String, + compuMethods: Map + ): A2lMeasurement { + val cleanedBlock = cleanA2lComments(block) + val tokens = tokenizeA2lBlock(cleanedBlock) + var description = "" + var dataTypeStr = "UBYTE" + var conversion = "NO_COMPU_METHOD" + var resolution = 1 + var accuracy = 0.0 + var lowerLimit = 0.0 + var upperLimit = 100.0 + var ecuAddress = 0L + var format = "%6.3f" + var unit = "" + val matrixDim = mutableListOf() + + val nameIdx = tokens.indexOfFirst { it.equals(measName, ignoreCase = true) } + if (nameIdx != -1) { + description = tokens.getOrNull(nameIdx + 1) ?: "" + dataTypeStr = tokens.getOrNull(nameIdx + 2) ?: "UBYTE" + conversion = tokens.getOrNull(nameIdx + 3) ?: "" + resolution = tokens.getOrNull(nameIdx + 4)?.toIntOrNull() ?: 1 + accuracy = tokens.getOrNull(nameIdx + 5)?.toDoubleOrNull() ?: 0.0 + lowerLimit = tokens.getOrNull(nameIdx + 6)?.toDoubleOrNull() ?: 0.0 + upperLimit = tokens.getOrNull(nameIdx + 7)?.toDoubleOrNull() ?: 100.0 + } + + val addrMatch = ECU_ADDRESS_REGEX.find(cleanedBlock) + if (addrMatch != null) { + val addrStr = addrMatch.groupValues[1] + ecuAddress = if (addrStr.startsWith("0x", ignoreCase = true)) { + addrStr.substring(2).toLongOrNull(16) ?: 0L + } else { + addrStr.toLongOrNull() ?: 0L + } + } + + val fmtMatch = FORMAT_REGEX.find(block) + if (fmtMatch != null) format = fmtMatch.groupValues[1] + + val unitMatch = UNIT_REGEX.find(block) + if (unitMatch != null) unit = unitMatch.groupValues[1] + + val dimMatch = MATRIX_DIM_REGEX.find(block) + if (dimMatch != null) { + val dims = dimMatch.groupValues[1].split("\\s+".toRegex()).mapNotNull { it.toIntOrNull() } + matrixDim.addAll(dims) + } + + val a2lType = A2lDataType.fromString(dataTypeStr) + + return A2lMeasurement( + name = measName, + description = description, + dataType = dataTypeStr, + a2lDataType = a2lType, + conversionMethod = conversion, + resolution = resolution, + accuracy = accuracy, + lowerLimit = lowerLimit, + upperLimit = upperLimit, + ecuAddress = ecuAddress, + format = format, + unit = unit, + matrixDim = matrixDim + ) + } + + private fun parseCharacteristicBlock(charName: String, block: String): RawCharacteristic { + val cleanedBlock = cleanA2lComments(block) + val tokens = tokenizeA2lBlock(cleanedBlock) + var description = "" + var type = "VALUE" + var ecuAddress = 0L + var deposit = "" + var maxDiff = 0.0 + var conversionMethod = "" + var lowerLimit = 0.0 + var upperLimit = 100.0 + var format = "%6.3f" + var unit = "" + val matrixDim = mutableListOf() + val values = mutableListOf() + + val nameIdx = tokens.indexOfFirst { it.equals(charName, ignoreCase = true) } + if (nameIdx != -1) { + description = tokens.getOrNull(nameIdx + 1) ?: "" + type = tokens.getOrNull(nameIdx + 2) ?: "VALUE" + val addrToken = tokens.getOrNull(nameIdx + 3) ?: "0" + ecuAddress = if (addrToken.startsWith("0x", ignoreCase = true)) { + addrToken.substring(2).toLongOrNull(16) ?: 0L + } else { + addrToken.toLongOrNull() ?: 0L + } + deposit = tokens.getOrNull(nameIdx + 4) ?: "" + maxDiff = tokens.getOrNull(nameIdx + 5)?.toDoubleOrNull() ?: 0.0 + conversionMethod = tokens.getOrNull(nameIdx + 6) ?: "" + lowerLimit = tokens.getOrNull(nameIdx + 7)?.toDoubleOrNull() ?: 0.0 + upperLimit = tokens.getOrNull(nameIdx + 8)?.toDoubleOrNull() ?: 100.0 + } + + val addrMatch = ECU_ADDRESS_REGEX.find(cleanedBlock) + if (addrMatch != null) { + val addrStr = addrMatch.groupValues[1] + ecuAddress = if (addrStr.startsWith("0x", ignoreCase = true)) { + addrStr.substring(2).toLongOrNull(16) ?: 0L + } else { + addrStr.toLongOrNull() ?: 0L + } + } + + val fmtMatch = FORMAT_REGEX.find(cleanedBlock) + if (fmtMatch != null) format = fmtMatch.groupValues[1] + + val unitMatch = UNIT_REGEX.find(cleanedBlock) + if (unitMatch != null) unit = unitMatch.groupValues[1] + + val dimMatch = MATRIX_DIM_REGEX.find(cleanedBlock) + if (dimMatch != null) { + val dims = dimMatch.groupValues[1].split("\\s+".toRegex()).mapNotNull { it.toIntOrNull() } + matrixDim.addAll(dims) + } else { + val numMatch = NUMBER_REGEX.find(cleanedBlock) + if (numMatch != null) { + numMatch.groupValues[1].toIntOrNull()?.let { matrixDim.add(it) } + } + } + + val defMatch = DEFAULT_VALUE_REGEX.find(cleanedBlock) + if (defMatch != null) { + defMatch.groupValues[1].toDoubleOrNull()?.let { values.add(it) } + } + + return RawCharacteristic( + name = charName, + description = description, + type = type, + ecuAddress = ecuAddress, + deposit = deposit, + maxDiff = maxDiff, + conversionMethod = conversionMethod, + lowerLimit = lowerLimit, + upperLimit = upperLimit, + format = format, + unit = unit, + matrixDim = matrixDim, + values = values, + dataType = mapDepositToDataTypeName(deposit) + ) + } + + private fun mapDepositToDataTypeName(deposit: String): String { + val upper = deposit.uppercase() + return when { + upper.contains("UWORD") || upper.contains("UINT16") -> "UWORD" + upper.contains("SWORD") || upper.contains("INT16") -> "SWORD" + upper.contains("ULONG") || upper.contains("UINT32") -> "ULONG" + upper.contains("SLONG") || upper.contains("INT32") -> "SLONG" + upper.contains("FLOAT") -> "FLOAT32_IEEE" + upper.contains("DOUBLE") -> "FLOAT64_IEEE" + upper.contains("SBYTE") || upper.contains("INT8") -> "SBYTE" + else -> "UBYTE" + } + } + + private fun tokenizeA2lBlock(text: String): List { + val tokens = mutableListOf() + val sb = StringBuilder() + var inQuote = false + + for (c in text) { + if (c == '"') { + if (inQuote) { + tokens.add(sb.toString()) + sb.clear() + inQuote = false + } else { + if (sb.isNotBlank()) { + tokens.add(sb.toString()) + sb.clear() + } + inQuote = true + } + } else if (c.isWhitespace() && !inQuote) { + if (sb.isNotEmpty()) { + tokens.add(sb.toString()) + sb.clear() + } + } else { + sb.append(c) + } + } + if (sb.isNotEmpty()) { + tokens.add(sb.toString()) + } + return tokens + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/parser/CanDbcParser.kt b/Android/app/src/main/java/com/dct/app/data/parser/CanDbcParser.kt new file mode 100644 index 0000000..74b352d --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/parser/CanDbcParser.kt @@ -0,0 +1,129 @@ +package com.dct.app.data.parser + +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.CanMultiplexRole +import com.dct.app.domain.model.mdb.SignalDefinition +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader + +/** + * CAN DBC 정의 파일 파서 (BO_ / SG_ / CM_ SG_ 파싱 및 SignalDefinition 변환) + */ +object CanDbcParser { + // DBC BO_, SG_ 및 CM_ 정규식 + private val BO_REGEX = Regex("""^\s*BO_\s+(\d+)\s+(\w+)\s*:\s*(\d+)\s+.*""") + private val SG_REGEX = Regex("""^\s*SG_\s+(\w+)(?:\s+(M|m\d+))?\s*:\s*(\d+)\|(\d+)@([01][+-])\s*\(([^,]+),([^)]+)\)\s*\[([^|]+)\|([^\]]+)\]\s*"([^"]*)"\s*(.*)$""") + private val CM_SG_REGEX = Regex("""^\s*CM_\s+SG_\s+(\d+)\s+(\w+)\s+"([^"]*)"\s*;?""") + + fun parseDbc(inputStream: InputStream): List { + val signals = mutableListOf() + val commentsMap = mutableMapOf() + var signalIndex = 0 + var currentMsgId = 0L + var currentMsgName = "" + var currentMsgDlc = 8 + + val allLines = com.dct.app.core.util.TextStreamDecoder.readLinesSmart(inputStream) + + // 1차 패스: CM_ SG_ 코멘트 수집 + for (line in allLines) { + val cmMatch = CM_SG_REGEX.find(line) + if (cmMatch != null) { + val (msgIdStr, sigName, comment) = cmMatch.destructured + commentsMap["${msgIdStr}_$sigName"] = comment + } + } + + // 2차 패스: BO_ 및 SG_ 신호 파싱 + for (line in allLines) { + val boMatch = BO_REGEX.find(line) + if (boMatch != null) { + val (idStr, msgName, dlcStr) = boMatch.destructured + currentMsgId = idStr.toLongOrNull() ?: 0L + currentMsgName = msgName + currentMsgDlc = dlcStr.toIntOrNull()?.coerceIn(0, 64) ?: 8 + continue + } + + val match = SG_REGEX.find(line) + if (match != null) { + val name = match.groupValues[1] + val multiplexToken = match.groupValues[2] + val startBitStr = match.groupValues[3] + val lengthStr = match.groupValues[4] + val endianSign = match.groupValues[5] + val factorStr = match.groupValues[6] + val offsetStr = match.groupValues[7] + val minStr = match.groupValues[8] + val maxStr = match.groupValues[9] + val unit = match.groupValues[10] + val cleanUnit = com.dct.app.core.util.TextStreamDecoder.sanitizeUnit(unit) + val startBit = startBitStr.toIntOrNull() ?: 0 + val length = lengthStr.toIntOrNull() ?: 8 + val factor = factorStr.toDoubleOrNull() ?: 1.0 + val offset = offsetStr.toDoubleOrNull() ?: 0.0 + val min = minStr.toDoubleOrNull() ?: 0.0 + val max = maxStr.toDoubleOrNull() ?: 100.0 + val isSigned = endianSign.endsWith("-") + val multiplexRole = when { + multiplexToken.isEmpty() -> CanMultiplexRole.NONE + multiplexToken == "M" -> CanMultiplexRole.MULTIPLEXOR + multiplexToken.startsWith("m") -> CanMultiplexRole.MULTIPLEXED + else -> CanMultiplexRole.NONE + } + val multiplexValue = if (multiplexRole == CanMultiplexRole.MULTIPLEXED) { + multiplexToken.drop(1).toLongOrNull() + } else { + null + } + + val attr = when { + length <= 8 && isSigned -> MdbAttrType.ATTR_1_INT8 + length <= 8 -> MdbAttrType.ATTR_0_UINT8 + length <= 16 && isSigned -> MdbAttrType.ATTR_3_INT16 + length <= 16 -> MdbAttrType.ATTR_2_UINT16 + isSigned -> MdbAttrType.ATTR_5_INT32 + else -> MdbAttrType.ATTR_4_UINT32 + } + + // PC C++ DBC 로드 로직 100% 호환 (신호 이름 및 메시지 ID 기반 고정 시드로 일정한 Windows RGB 생성) + // Windows COLORREF: RGB(R, G, B) = R | (G << 8) | (B << 16) + val seed = name.hashCode().toLong() xor (currentMsgId shl 16) + val rnd = kotlin.random.Random(seed) + val r = rnd.nextInt(256) + val g = rnd.nextInt(256) + val b = rnd.nextInt(256) + val color = (r.toLong() or (g.toLong() shl 8) or (b.toLong() shl 16)) + + // PC C++ 버전은 DBC 파일 내 CM_ 주석을 파싱하지 않으므로 기본 Description은 빈칸("")이며, .dpc 프로젝트 파일에서 로드함 + val finalDesc = "" + + signals.add( + SignalDefinition( + reclid = 0, + idx = signalIndex++, + name = name, + attr = attr, + cvsA = factor, + cvsB = offset, + unit = cleanUnit, + color = color, + min = min, + max = max, + description = finalDesc, + messageName = currentMsgName, + messageId = currentMsgId, + messageDlc = currentMsgDlc, + startBit = startBit, + bitLength = length, + isMotorola = endianSign.startsWith("0"), + multiplexRole = multiplexRole, + multiplexValue = multiplexValue + ) + ) + } + } + return signals + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/ChecksumCalculator.kt b/Android/app/src/main/java/com/dct/app/data/protocol/ChecksumCalculator.kt new file mode 100644 index 0000000..cd64c1f --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/ChecksumCalculator.kt @@ -0,0 +1,63 @@ +package com.dct.app.data.protocol + +/** + * DCT 바이너리 패킷 체크섬 계산기 인터페이스. + * 8비트 누적 합산에 대한 1의 보수(1's complement / NOT / XOR 0xFF) 방식을 사용합니다. + */ +interface ChecksumCalculator { + /** + * 주어진 데이터 바이트 배열의 특정 영역에 대한 8비트 NOT 누적 합산 체크섬을 계산합니다. + * + * @param data 대상 바이트 배열 + * @param offset 시작 위치 (0-indexed) + * @param length 계산할 바이트 수 + * @return 8비트 체크섬 바이트 + */ + fun calculate(data: ByteArray, offset: Int = 0, length: Int = data.size): Byte + + /** + * 완전한 프레임 바이트 배열의 체크섬 필드가 유효한지 검증합니다. + * 프레임 구조: [START(1B)] [LenH(1B)] [LenL(1B)] [CmdID(1B)] [Payload(N-1 B)] [Checksum(1B)] [END(1B)] + * 체크섬 위치: packet[offset + length - 2] + * 계산 범위: packet[offset .. offset + length - 3] + * + * @param packet 완전한 프레임 바이트 배열 + * @param offset 프레임 시작 오프셋 + * @param length 전체 프레임 길이 (최소 6바이트 이상) + * @return 체크섬 일치 여부 + */ + fun verify(packet: ByteArray, offset: Int = 0, length: Int = packet.size): Boolean +} + +/** + * [ChecksumCalculator]의 기본 구현체. + * PC C++ MainFrm.cpp 및 ConnectSocket.cpp의 알고리즘과 100% 동일하게 동작합니다. + */ +class DefaultChecksumCalculator : ChecksumCalculator { + + override fun calculate(data: ByteArray, offset: Int, length: Int): Byte { + require(offset >= 0) { "Offset must be non-negative: $offset" } + require(length >= 0) { "Length must be non-negative: $length" } + require(offset + length <= data.size) { + "Offset ($offset) + Length ($length) exceeds data size (${data.size})" + } + + var sum = 0 + val end = offset + length + for (i in offset until end) { + sum = (sum + (data[i].toInt() and 0xFF)) and 0xFF + } + return (sum xor 0xFF).toByte() + } + + override fun verify(packet: ByteArray, offset: Int, length: Int): Boolean { + if (length < 6) return false + if (offset < 0 || offset + length > packet.size) return false + + val checksumIndex = offset + length - 2 + val expectedChecksum = packet[checksumIndex] + val calculatedChecksum = calculate(packet, offset, length - 2) + + return expectedChecksum == calculatedChecksum + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctCalibrationProtocol.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctCalibrationProtocol.kt new file mode 100644 index 0000000..b84a71b --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctCalibrationProtocol.kt @@ -0,0 +1,58 @@ +package com.dct.app.data.protocol + +import com.dct.app.data.mdb.AttrConverter +import com.dct.app.domain.model.mdb.* +import com.dct.app.domain.model.protocol.* + +/** Calibration wire values are big endian (unlike the little-endian measurement stream). */ +object DctCalibrationProtocol { + fun encodeInteger(value: Double, attr: MdbAttrType): ByteArray { + require(value.isFinite() && value == kotlin.math.floor(value)) { "정수 데이터만 전송할 수 있습니다." } + val bits = attr.byteSize * 8 + val min = if (attr.isSigned) -(1L shl (bits - 1)) else 0L + val max = if (attr.isSigned) (1L shl (bits - 1)) - 1 else (1L shl bits) - 1 + require(value >= min.toDouble() && value <= max.toDouble()) { "값이 ${attr.name} 범위를 벗어났습니다." } + return AttrConverter.packRawValue(value.toLong(), attr).reversedArray() + } + + fun decodeInteger(bytes: ByteArray, attr: MdbAttrType): Double { + require(bytes.size == attr.byteSize) { "보정 데이터 길이가 일치하지 않습니다." } + return AttrConverter.unpackRawValue(bytes.reversedArray(), 0, attr).toDouble() + } + + fun mapRequest(map: CompensationMap, xAxis: Boolean, write: Boolean): DctPacket { + val address = if (xAxis) map.xAddr else map.yAddr + val attr = if (xAxis) map.xAttr else map.yAttr + require(address in 0..0xFFFFFF) { "보정 맵 주소는 24비트 범위여야 합니다." } + require(map.points.size in 1..255) { "보정 맵 포인트 수는 1~255여야 합니다." } + require(address + map.points.size.toLong() * attr.byteSize - 1 <= 0xFFFFFF) { "보정 맵 끝 주소가 범위를 벗어났습니다." } + require(map.points.map { it.idx }.distinct().size == map.points.size) { "중복 포인트 인덱스입니다." } + val operation = (if (write) 3 else 1) + if (xAxis) 0 else 1 + val header = byteArrayOf(operation.toByte(), (address shr 16).toByte(), (address shr 8).toByte(), + address.toByte(), attr.byteSize.toByte(), map.points.size.toByte()) + val values = if (write) map.points.sortedBy { it.idx }.flatMap { point -> + val stored = if (xAxis) point.bkp else point.data + val value = if (attr == MdbAttrType.ATTR_4_UINT32) (stored.toLong() and 0xFFFFFFFFL).toDouble() else stored.toDouble() + encodeInteger(value, attr).toList() + }.toByteArray() else byteArrayOf() + return DctPacket.create(DctCommand.CMD_COMPENSATION_TYPE_DATA, header + values) + } + + fun mapResponse(map: CompensationMap, xAxis: Boolean, write: Boolean, response: DctPacket): CompensationMap { + val request = mapRequest(map, xAxis, write).payload + val data = response.payload + require(response.command == DctCommand.RES_COMPENSATION_TYPE_DATA.code) { "보정 응답 명령이 일치하지 않습니다." } + require(data.size >= 7 && data[0] == 1.toByte()) { "장비가 보정 요청을 거절했거나 응답이 불완전합니다." } + require(data.copyOfRange(1, 7).contentEquals(request.copyOfRange(0, 6))) { "보정 응답의 작업/주소/속성/개수가 일치하지 않습니다." } + if (write) return map + val attr = if (xAxis) map.xAttr else map.yAttr + require(data.size == 7 + map.points.size * attr.byteSize) { "보정 맵 응답 길이가 일치하지 않습니다." } + val points = map.points.sortedBy { it.idx }.mapIndexed { index, point -> + val offset = 7 + index * attr.byteSize + val value = decodeInteger(data.copyOfRange(offset, offset + attr.byteSize), attr) + val updated = if (xAxis) point.copy(bkp = value.toLong().toInt()) else point.copy(data = value.toLong().toInt()) + if (xAxis == map.isTypeA) updated.copy(phy = value * map.cvsA + map.cvsB) else updated + } + return map.copy(points = points) + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctPacketCodec.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctPacketCodec.kt new file mode 100644 index 0000000..6a0a5fd --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctPacketCodec.kt @@ -0,0 +1,135 @@ +package com.dct.app.data.protocol + +import com.dct.app.domain.model.protocol.DctPacket + +/** + * DCT 바이너리 패킷 코덱 (인코더 / 디코더). + * + * 프레임 포맷: + * [Header (0xA1)] [Len_H (1B)] [Len_L (1B)] [CmdID (1B)] [Payload (N-1 B)] [Checksum (1B)] [Footer (0x7E)] + * + * packLen = Payload.size + 1 (CmdID 1바이트 포함) + * Total Frame Length = packLen + 5 + */ +class DctPacketCodec( + private val checksumCalculator: ChecksumCalculator = DefaultChecksumCalculator() +) { + companion object { + const val START_BYTE: Byte = 0xA1.toByte() + const val END_BYTE: Byte = 0x7E.toByte() + const val MIN_PACKET_LENGTH: Int = 6 // START(1) + LenH(1) + LenL(1) + Cmd(1) + Checksum(1) + END(1) + const val HEADER_LENGTH: Int = 4 // START + LenH + LenL + Cmd + const val OVERHEAD_LENGTH: Int = 5 // START(1) + LenH(1) + LenL(1) + Checksum(1) + END(1) + const val MAX_PAYLOAD_LENGTH: Int = 65530 + } + + /** + * [DctPacket]을 표준 바이너리 프레임 바이트 배열로 인코딩합니다. + * + * @param packet 인코딩할 DctPacket + * @return 0xA1으로 시작하고 0x7E로 끝나는 완전한 바이너리 프레임 바이트 배열 + */ + fun encode(packet: DctPacket): ByteArray { + val payload = packet.payload + val payloadSize = payload.size + require(payloadSize <= MAX_PAYLOAD_LENGTH) { "Payload size exceeds maximum allowed: $payloadSize" } + + val packLen = payloadSize + 1 // CmdID 포함 + val totalLength = packLen + OVERHEAD_LENGTH + val frame = ByteArray(totalLength) + + // 1. Header & Length (Big-Endian) + frame[0] = START_BYTE + frame[1] = ((packLen shr 8) and 0xFF).toByte() + frame[2] = (packLen and 0xFF).toByte() + + // 2. Command ID + frame[3] = packet.command + + // 3. Payload Data + if (payloadSize > 0) { + System.arraycopy(payload, 0, frame, 4, payloadSize) + } + + // 4. Checksum calculation over frame[0 .. totalLength - 3] + val checksum = checksumCalculator.calculate(frame, 0, totalLength - 2) + frame[totalLength - 2] = checksum + + // 5. Footer + frame[totalLength - 1] = END_BYTE + + return frame + } + + /** + * 바이트 배열에서 단일 패킷 프레임을 디코딩합니다. + * + * @param frame 검증할 프레임 바이트 배열 + * @param offset 시작 오프셋 + * @param length 검증할 프레임 길이 + * @param sequence 디코딩될 DctPacket에 부여할 시퀀스 번호 (기본 0) + * @return 디코딩 성공 시 [DctPacket], 실패 시 null + */ + fun decode( + frame: ByteArray, + offset: Int = 0, + length: Int = frame.size, + sequence: Int = 0 + ): DctPacket? { + if (length < MIN_PACKET_LENGTH) return null + if (offset < 0 || offset + length > frame.size) return null + + // 1. Start & End Byte 검증 + if (frame[offset] != START_BYTE || frame[offset + length - 1] != END_BYTE) { + return null + } + + // 2. packLen 필드 추출 (Big-Endian) + val lenH = frame[offset + 1].toInt() and 0xFF + val lenL = frame[offset + 2].toInt() and 0xFF + val packLen = (lenH shl 8) or lenL + + // 3. 길이 정합성 검증 (totalLength == packLen + 5) + if (length != packLen + OVERHEAD_LENGTH) { + return null + } + + // 4. 체크섬 검증 + if (!checksumCalculator.verify(frame, offset, length)) { + return null + } + + // 5. Command 및 Payload 추출 + val command = frame[offset + 3] + val payloadSize = packLen - 1 + val payload = if (payloadSize > 0) { + val payloadArr = ByteArray(payloadSize) + System.arraycopy(frame, offset + 4, payloadArr, 0, payloadSize) + payloadArr + } else { + ByteArray(0) + } + + return DctPacket( + command = command, + payload = payload, + sequence = sequence + ) + } + + /** + * 주어진 바이트 배열의 특정 영역이 유효한 패킷 프레임인지 확인합니다. + */ + fun isValidFrame(frame: ByteArray, offset: Int = 0, length: Int = frame.size): Boolean { + if (length < MIN_PACKET_LENGTH) return false + if (offset < 0 || offset + length > frame.size) return false + if (frame[offset] != START_BYTE || frame[offset + length - 1] != END_BYTE) return false + + val lenH = frame[offset + 1].toInt() and 0xFF + val lenL = frame[offset + 2].toInt() and 0xFF + val packLen = (lenH shl 8) or lenL + + if (length != packLen + OVERHEAD_LENGTH) return false + return checksumCalculator.verify(frame, offset, length) + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt new file mode 100644 index 0000000..dc8393e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt @@ -0,0 +1,363 @@ +package com.dct.app.data.protocol + +import com.dct.app.data.parser.A2lParser +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.protocol.DctCanMessageConfiguration +import com.dct.app.domain.model.protocol.DctCommand +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.model.project.DpcSensorModuleConfig +import java.io.ByteArrayOutputStream +import java.time.LocalDateTime + +/** + * PC DCT 클라이언트와 동일한 순서로 명령 payload를 직렬화합니다. + * 화면과 Repository가 임의로 바이트 배열을 만들지 않도록 모든 핵심 요청을 이곳에 고정합니다. + */ +object DctProtocolRequests { + private val dayOfWeekFlags = byteArrayOf( + 0x40, // Monday + 0x20, // Tuesday + 0x10, // Wednesday + 0x08, // Thursday + 0x04, // Friday + 0x02, // Saturday + 0x01 // Sunday + ) + + fun enable(): DctPacket = DctPacket.create(DctCommand.CMD_ENABLE) + fun version(): DctPacket = DctPacket.create(DctCommand.CMD_VERSION) + fun hardwareId(): DctPacket = DctPacket.create(DctCommand.CMD_HWID) + fun disable(): DctPacket = DctPacket.create(DctCommand.CMD_DISABLE) + fun startAll(): DctPacket = DctPacket.create(DctCommand.CMD_START_TRANSMIT_DATA, byteArrayOf(0x01)) + fun stop(): DctPacket = DctPacket.create(DctCommand.CMD_STOP_TRANSMIT_DATA) + fun canStreamAck(success: Boolean = true): DctPacket = statusPacket(DctCommand.CMD_CAN_FRAME_ACK, success) + fun smStreamAck(success: Boolean = true): DctPacket = statusPacket(DctCommand.CMD_SM_DATA_ACK, success) + fun xcpStreamAck(success: Boolean = true): DctPacket = statusPacket(DctCommand.CMD_XCP_MEASUREMENT_DATA, success) + + fun syncTime(time: LocalDateTime = LocalDateTime.now()): DctPacket { + val year = time.year + val payload = byteArrayOf( + time.hour.toByte(), + time.minute.toByte(), + time.second.toByte(), + ((year ushr 8) and 0xFF).toByte(), + (year and 0xFF).toByte(), + time.monthValue.toByte(), + time.dayOfMonth.toByte(), + dayOfWeekFlags[time.dayOfWeek.value - 1] + ) + return DctPacket.create(DctCommand.CMD_SYNC_TIME, payload) + } + + /** PC SendCarType: [ASCII 길이:1][차종 문자열:N][Maker:1]. */ + fun carType(vehicleCode: String, maker: Int): DctPacket { + val encoded = vehicleCode.toByteArray(Charsets.US_ASCII) + require(encoded.size <= 0xFF) { "Vehicle code is too long" } + return DctPacket.create( + DctCommand.CMD_CARTYPE, + byteArrayOf(encoded.size.toByte()) + encoded + byteArrayOf((maker and 0xFF).toByte()) + ) + } + + /** PC SendMDBPerceiveData와 동일한 ReclID/Index/ItemSize 등록 형식. */ + fun mdbInfo(signals: List): DctPacket { + val groups = signals.groupBy { it.reclid }.toSortedMap() + require(groups.size <= 0xFF) { "Too many MDB record groups" } + require(groups.keys.map { it and 0xFFFF }.distinct().size == groups.size) { + "Duplicate MDB record ID after 16-bit wire conversion" + } + val stream = ByteArrayOutputStream() + stream.write(groups.size) + groups.forEach { (recordId, recordSignals) -> + require(recordId >= 0) { "MDB record ID must not be negative" } + require(recordSignals.size <= 0xFFFF) { "Too many signals in MDB record $recordId" } + require(recordSignals.map { it.idx and 0xFFFF }.distinct().size == recordSignals.size) { + "Duplicate MDB signal index after 16-bit wire conversion in record $recordId" + } + writeUInt16Be(stream, recordId) + writeUInt16Be(stream, recordSignals.size) + recordSignals.sortedBy { it.idx }.forEach { signal -> + require(signal.idx >= 0) { "MDB signal index must not be negative" } + writeUInt16Be(stream, signal.idx) + stream.write(signal.attr.byteSize) + } + } + return DctPacket.create(DctCommand.CMD_MDB_INFO, stream.toByteArray()) + } + + /** PC SendMDBSelectedID: [그룹수][ReclID:1][신호수:1][Index:1]*. */ + fun mdbSelectedIds(signals: List): DctPacket { + val groups = signals.groupBy { it.reclid }.toSortedMap() + require(groups.size <= 0xFF) { "Too many selected MDB record groups" } + require(groups.keys.map { it and 0xFF }.distinct().size == groups.size) { + "Duplicate selected MDB record ID after 8-bit wire conversion" + } + val stream = ByteArrayOutputStream() + stream.write(groups.size) + groups.forEach { (recordId, recordSignals) -> + require(recordId >= 0) { "Selected MDB record ID must not be negative" } + require(recordSignals.size <= 0xFF) { "Too many selected signals in MDB record $recordId" } + require(recordSignals.map { it.idx and 0xFF }.distinct().size == recordSignals.size) { + "Duplicate selected MDB signal index after 8-bit wire conversion in record $recordId" + } + // PC MainFrm.cpp도 BYTE 배열 대입으로 ReclID의 하위 8비트를 전송한다. + stream.write(recordId and 0xFF) + stream.write(recordSignals.size) + recordSignals.sortedBy { it.idx }.forEach { signal -> + require(signal.idx >= 0) { "Selected MDB signal index must not be negative" } + // PC SendMDBSelectedID와 동일하게 Index의 하위 8비트를 전송한다. + stream.write(signal.idx and 0xFF) + } + } + return DctPacket.create(DctCommand.CMD_MDB_SELECTED_ID, stream.toByteArray()) + } + + /** PC SendCANDBCIDData: [사용:1][Count:2BE][CAN ID:2BE][DLC:1]*. */ + fun canDbcInfo(messages: List): DctPacket { + require(messages.size <= 0xFFFF) { "Too many CAN DBC messages" } + val stream = ByteArrayOutputStream() + stream.write(if (messages.isEmpty()) 0 else 1) + writeUInt16Be(stream, messages.size) + messages.sortedBy { it.canId }.forEach { message -> + require(message.canId in 0..0xFFFF_FFFFL) { "DCT CAN ID must fit unsigned 32-bit source value" } + require(message.dlc in 0..0xFF) { "CAN DLC must fit unsigned 8-bit" } + // PC HIBYTE/LOBYTE 직렬화와 동일하게 하위 16비트를 사용한다. + writeUInt16Be(stream, (message.canId and 0xFFFF).toInt()) + stream.write(message.dlc) + } + return DctPacket.create(DctCommand.CMD_CANDBC_INFO, stream.toByteArray()) + } + + /** PC RequestSMCount: [0x01][사용 모듈 비트맵][모듈별 온도채널 비트맵]*. */ + fun sensorModuleInfo(modules: List): DctPacket { + val normalized = modules.sortedBy { it.moduleIndex } + require(normalized.all { it.moduleIndex in 0..3 }) { "Sensor module index must be 0..3" } + var moduleMask = 0 + normalized.filter { it.isUsed }.forEach { moduleMask = moduleMask or (1 shl it.moduleIndex) } + val stream = ByteArrayOutputStream() + stream.write(0x01) + stream.write(moduleMask) + normalized.forEach { module -> + var temperatureMask = 0 + module.channels.take(8).forEach { channel -> + if (!channel.isVoltage) temperatureMask = temperatureMask or (1 shl channel.channelIndex) + } + stream.write(temperatureMask) + } + return DctPacket.create(DctCommand.CMD_SM_INFO, stream.toByteArray()) + } + + /** PC SetupCANMode: [0x06][CAN mode | old-model flag]. */ + fun canMode(isHighSpeedCan: Boolean, isOldModel: Boolean): DctPacket { + var mode = if (isHighSpeedCan) 0x01 else 0x00 + if (isOldModel) mode = mode or 0x10 + return DctPacket.create(DctCommand.CMD_DCT_ENV, byteArrayOf(0x06, mode.toByte())) + } + + fun constantWrite(address: Long, data: ByteArray): DctPacket { + require(data.isNotEmpty() && data.size <= 0xFF) { "Constant data size must be 1..255" } + val stream = ByteArrayOutputStream() + writeVariableAddress(stream, address) + stream.write(data.size) + stream.write(data) + return DctPacket.create(DctCommand.CMD_MDB_CONSTANT_DATA, stream.toByteArray()) + } + + fun constantRead(address: Long, size: Int): DctPacket { + require(size in 1..0xFF) { "Constant data size must be 1..255" } + val stream = ByteArrayOutputStream() + writeVariableAddress(stream, address) + stream.write(size) + return DctPacket.create(DctCommand.CMD_MDB_REQ_CONSTANT_DATA, stream.toByteArray()) + } + + fun xcpUse(mode: Int): DctPacket = + DctPacket.create(DctCommand.CMD_XCP_USE, byteArrayOf((mode and 0xFF).toByte())) + + fun xcpIfData(ifData: A2lParser.XcpIfData): DctPacket { + val stream = ByteArrayOutputStream() + stream.write(if (ifData.isMsbFirst) 0x01 else 0x00) + writeUInt32(stream, ifData.masterCanId, ifData.isMsbFirst) + writeUInt32(stream, ifData.slaveCanId, ifData.isMsbFirst) + return DctPacket.create(DctCommand.CMD_XCP_IFDATAS, stream.toByteArray()) + } + + fun xcpMeasurements(measurements: List, isMsbFirst: Boolean): DctPacket { + require(measurements.size <= 0xFFFF) { "Too many XCP measurements" } + val stream = ByteArrayOutputStream() + stream.write((measurements.size ushr 8) and 0xFF) + stream.write(measurements.size and 0xFF) + measurements.forEach { signal -> + stream.write(signal.a2lDataType?.code ?: signal.attr.toA2lTypeCode()) + writeUInt32(stream, signal.adl.toLong() and 0xFFFF_FFFFL, isMsbFirst) + } + return DctPacket.create(DctCommand.CMD_XCP_MEASUREMENTS, stream.toByteArray()) + } + + fun xcpCharacteristicWrite( + address: Long, + dataType: A2lDataType, + value: Double, + isMsbFirst: Boolean + ): DctPacket { + val stream = ByteArrayOutputStream() + stream.write(dataType.code) + require(address + dataType.byteSize - 1 in 0..0xFFFF_FFFFL) { "XCP 데이터 끝 주소가 범위를 벗어났습니다." } + stream.write(XcpValueCodec.encode(dataType, value, isMsbFirst)) + writeUInt32(stream, address, isMsbFirst) + return DctPacket.create(DctCommand.CMD_XCP_CHARACTERISTIC_DATA, stream.toByteArray()) + } + + fun xcpCharacteristicRead( + address: Long, + dataType: A2lDataType, + isMsbFirst: Boolean + ): DctPacket { + require(address + dataType.byteSize - 1 in 0..0xFFFF_FFFFL) { "XCP 데이터 끝 주소가 범위를 벗어났습니다." } + val stream = ByteArrayOutputStream() + stream.write(dataType.code) + writeUInt32(stream, address, isMsbFirst) + return DctPacket.create(DctCommand.CMD_XCP_REQ_CHARACTERISTIC_DATA, stream.toByteArray()) + } + + /** PC RequestLogfileCount: [0x01]. */ + fun savedLogCount(): DctPacket = + DctPacket.create(DctCommand.CMD_SAVED_LOGFILE_INFO, byteArrayOf(0x01)) + + /** PC RequestFileInformation: [0x02][index:2BE][count:1]. */ + fun savedLogInfo(index: Int, count: Int = 1): DctPacket { + require(index in 0..0xFFFF) { "Log index must fit unsigned 16-bit" } + require(count in 1..0xFF) { "Log request count must be 1..255" } + return DctPacket.create( + DctCommand.CMD_SAVED_LOGFILE_INFO, + byteArrayOf(0x02, (index ushr 8).toByte(), index.toByte(), count.toByte()) + ) + } + + /** PC RequestLogfileLength: [0x01][index:2BE]. */ + fun savedLogLength(index: Int): DctPacket { + require(index in 0..0xFFFF) { "Log index must fit unsigned 16-bit" } + return DctPacket.create( + DctCommand.CMD_SAVED_LOGFILE_COPY, + byteArrayOf(0x01, (index ushr 8).toByte(), index.toByte()) + ) + } + + /** PC RequestSavedFileData: [0x02][index:2BE][offset:4BE][length:2BE]. */ + fun savedLogChunk(index: Int, offset: Long, length: Int): DctPacket { + require(index in 0..0xFFFF) { "Log index must fit unsigned 16-bit" } + require(offset in 0..0xFFFF_FFFFL) { "Log offset must fit unsigned 32-bit" } + require(length in 1..0xFFFF) { "Log chunk length must be 1..65535" } + val stream = ByteArrayOutputStream() + stream.write(0x02) + writeUInt16Be(stream, index) + writeUInt32Be(stream, offset) + writeUInt16Be(stream, length) + return DctPacket.create(DctCommand.CMD_SAVED_LOGFILE_COPY, stream.toByteArray()) + } + + /** PC RequestFWCount: [0x04]. */ + fun firmwareCount(): DctPacket = + DctPacket.create(DctCommand.CMD_FW_INFO, byteArrayOf(0x04)) + + /** PC RequestFWInformation: [0x01][index:1]. */ + fun firmwareInfo(index: Int): DctPacket { + require(index in 0..0xFF) { "Firmware index must fit unsigned 8-bit" } + return DctPacket.create(DctCommand.CMD_FW_INFO, byteArrayOf(0x01, index.toByte())) + } + + /** PC SendFileLength: [0x02][slot:1][sourceLength:4BE][nameLength:1][ASCII name][S19 start:4BE?]. */ + fun firmwareMetadata( + slot: Int, + sourceLength: Long, + fileName: String, + s19StartAddress: Long? = null + ): DctPacket { + require(slot in 0..0xFF) { "Firmware slot must fit unsigned 8-bit" } + require(sourceLength in 1..0xFFFF_FFFFL) { "Firmware source length must fit unsigned 32-bit" } + val name = fileName.toByteArray(Charsets.US_ASCII) + require(name.isNotEmpty() && name.size <= 0xFF) { "Firmware filename must be 1..255 ASCII bytes" } + require(name.all { (it.toInt() and 0xFF) in 0x20..0x7E }) { "Firmware filename must contain ASCII printable characters only" } + val stream = ByteArrayOutputStream() + stream.write(0x02) + stream.write(slot) + writeUInt32Be(stream, sourceLength) + stream.write(name.size) + stream.write(name) + s19StartAddress?.let { + require(it in 0..0xFFFF_FFFFL) { "S19 start address must fit unsigned 32-bit" } + writeUInt32Be(stream, it) + } + return DctPacket.create(DctCommand.CMD_FW_INFO, stream.toByteArray()) + } + + /** PC CHANGE_BIN_05 SendFWData: [0x05][slot:1][chunkIndex:2BE][status][length:2BE][data]. */ + fun firmwareChunk(slot: Int, chunkIndex: Int, isLast: Boolean, data: ByteArray): DctPacket { + require(slot in 0..0xFF) { "Firmware slot must fit unsigned 8-bit" } + require(chunkIndex in 0..0xFFFF) { "Firmware chunk index must fit unsigned 16-bit" } + require(data.isNotEmpty() && data.size <= 512) { "Firmware chunk must be 1..512 bytes" } + val stream = ByteArrayOutputStream() + stream.write(0x05) + stream.write(slot) + writeUInt16Be(stream, chunkIndex) + stream.write(if (isLast) 0x02 else 0x01) + writeUInt16Be(stream, data.size) + stream.write(data) + return DctPacket.create(DctCommand.CMD_FW_INFO, stream.toByteArray()) + } + + /** PC SendASKKeyValue: [0x02][0x01][keyLength][key][firmwareAddress:4BE][STMin]. */ + fun firmwareKeySuccess(key: ByteArray, firmwareAddress: Long, stMin: Int): DctPacket { + require(key.isNotEmpty() && key.size <= 8) { "ASK key length must be 1..8" } + require(firmwareAddress in 0..0xFFFF_FFFFL) { "ASK firmware address must fit unsigned 32-bit" } + require(stMin in 0..0xFF) { "ASK STMin must fit unsigned 8-bit" } + val stream = ByteArrayOutputStream() + stream.write(0x02) + stream.write(0x01) + stream.write(key.size) + stream.write(key) + writeUInt32Be(stream, firmwareAddress) + stream.write(stMin) + return DctPacket.create(DctCommand.CMD_FW_KEY_VALUE, stream.toByteArray()) + } + + /** PC SendASKKeyValueError: [0x02][0x02][0x00]. */ + fun firmwareKeyFailure(): DctPacket = + DctPacket.create(DctCommand.CMD_FW_KEY_VALUE, byteArrayOf(0x02, 0x02, 0x00)) + + private fun statusPacket(command: DctCommand, success: Boolean): DctPacket = + DctPacket.create(command, byteArrayOf((if (success) 0x01 else 0x00).toByte())) + + private fun writeVariableAddress(stream: ByteArrayOutputStream, address: Long) { + require(address in 0..0xFFFF_FFFFL) { "Address must fit unsigned 32-bit" } + val byteCount = if (address > 0x00FF_FFFFL) 4 else 3 + stream.write(byteCount) + for (index in byteCount - 1 downTo 0) { + stream.write(((address ushr (index * 8)) and 0xFF).toInt()) + } + } + + private fun writeUInt16Be(stream: ByteArrayOutputStream, value: Int) { + stream.write((value ushr 8) and 0xFF) + stream.write(value and 0xFF) + } + + private fun writeUInt32Be(stream: ByteArrayOutputStream, value: Long) { + require(value in 0..0xFFFF_FFFFL) { "Value must fit unsigned 32-bit" } + for (index in 3 downTo 0) stream.write(((value ushr (index * 8)) and 0xFF).toInt()) + } + + private fun writeUInt32(stream: ByteArrayOutputStream, value: Long, isMsbFirst: Boolean) { + require(value in 0..0xFFFF_FFFFL) { "Value must fit unsigned 32-bit" } + val indices = if (isMsbFirst) (3 downTo 0) else (0..3) + for (index in indices) stream.write(((value ushr (index * 8)) and 0xFF).toInt()) + } + + private fun com.dct.app.domain.model.mdb.MdbAttrType.toA2lTypeCode(): Int = when (byteSize) { + 1 -> if (isSigned) A2lDataType.SBYTE.code else A2lDataType.UBYTE.code + 2 -> if (isSigned) A2lDataType.SWORD.code else A2lDataType.UWORD.code + 4 -> if (isSigned) A2lDataType.SLONG.code else A2lDataType.ULONG.code + else -> A2lDataType.UBYTE.code + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctResponseValidator.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctResponseValidator.kt new file mode 100644 index 0000000..f8e1233 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctResponseValidator.kt @@ -0,0 +1,43 @@ +package com.dct.app.data.protocol + +import com.dct.app.domain.model.protocol.DctPacket + +/** Command-specific responses: subtype bytes must never be mistaken for status bytes. */ +object DctResponseValidator { + fun validateSetup(request: DctPacket, response: DctPacket) { + val command = request.command.toInt() and 0xFF + require((response.command.toInt() and 0xFF) == command + 0x80) { "응답 명령 불일치" } + val data = response.payload + when (command) { + 0x01 -> validateVersion(data) + 0x03 -> require(data.size == 6) { "HW ID 응답은 6바이트여야 합니다." } + 0x09, 0x12 -> { + // Older peers, including DCT_test, return a single status byte. + if (data.size == 1) require(data[0] == 1.toByte()) { "장비가 설정을 거절했습니다." } + else { + require(data.size == 2 && data[0] == request.payload[0]) { "설정 응답 하위 명령/길이 오류" } + require(data[1] == 1.toByte()) { "장비가 설정을 거절했습니다." } + } + } + else -> require(data.size == 1 && data[0] == 1.toByte()) { "장비가 요청을 거절했거나 상태 응답이 잘못되었습니다." } + } + } + + private fun validateVersion(data: ByteArray) { + // PC MainFrm.cpp RES_VERSION: [length:1][version:length]. + val declaredLength = data.firstOrNull()?.toInt()?.and(0xFF) ?: 0 + if (declaredLength > 0 && data.size == declaredLength + 1) return + + // Already deployed DCT_test builds sent only "DCT-MOBI-v1.0.0\0". + // Accept only that identifiable legacy format, not arbitrary text or a + // truncated length-prefixed firmware response. New test builds use the PC format. + if (data.size in 2..256 && data.last() == 0.toByte() && + legacyTestVersion.matches(String(data, 0, data.size - 1, Charsets.US_ASCII))) return + + throw IllegalArgumentException( + "버전 응답 길이 오류 (수신 ${data.size}바이트, 선언 길이 ${declaredLength}바이트)" + ) + } + + private val legacyTestVersion = Regex("DCT-MOBI-v[0-9]+\\.[0-9]+\\.[0-9]+") +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt new file mode 100644 index 0000000..9ea75fb --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt @@ -0,0 +1,208 @@ +package com.dct.app.data.protocol + +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.mdb.CanMultiplexRole +import com.dct.app.domain.model.mdb.SignalDefinition + +/** 검증된 0xA3/0xA4 payload 구조. 잘린 데이터는 null을 반환합니다. */ +object DctStreamPayloadParser { + sealed interface CanStreamPayload { + data class Mdb(val recordId: Int, val data: ByteArray) : CanStreamPayload + data class Can( + val currentPage: Int, + val totalPages: Int, + val frames: List + ) : CanStreamPayload + } + + data class CanFrame( + val canId: Long, + val data: ByteArray, + val isReceived: Boolean + ) + + data class SensorModule(val moduleIndex: Int, val channels: List) + data class SensorChannel(val channelIndex: Int, val value: Double?, val isNotConnected: Boolean) + data class XcpStream(val values: List) + data class XcpValue(val dataType: A2lDataType, val data: ByteArray) + + fun parseCanStream(payload: ByteArray): CanStreamPayload? { + if (payload.size < 2) return null + return when (payload[0].toInt() and 0xFF) { + 0x01 -> CanStreamPayload.Mdb( + recordId = payload[1].toInt() and 0xFF, + data = payload.copyOfRange(2, payload.size) + ) + 0x02 -> parseCanFrames(payload) + else -> null + } + } + + private fun parseCanFrames(payload: ByteArray): CanStreamPayload.Can? { + if (payload.size < 4) return null + val currentPage = payload[1].toInt() and 0xFF + val totalPages = payload[2].toInt() and 0xFF + val frameCount = payload[3].toInt() and 0xFF + var offset = 4 + val frames = ArrayList(frameCount) + repeat(frameCount) { + if (offset + 3 > payload.size) return null + val canId = (payload[offset].toLong() and 0xFF) or + ((payload[offset + 1].toLong() and 0xFF) shl 8) + val lengthAndFlag = payload[offset + 2].toInt() and 0xFF + val length = lengthAndFlag and 0x7F + if (length > 64 || offset + 3 + length > payload.size) return null + frames += CanFrame( + canId = canId, + data = payload.copyOfRange(offset + 3, offset + 3 + length), + isReceived = (lengthAndFlag and 0x80) == 0 + ) + offset += 3 + length + } + if (offset != payload.size) return null + return CanStreamPayload.Can(currentPage, totalPages, frames) + } + + fun parseSensorStream(payload: ByteArray): List? { + if (payload.isEmpty()) return null + val moduleCount = payload[0].toInt() and 0xFF + var offset = 1 + val modules = ArrayList(moduleCount) + val seenModules = HashSet(moduleCount) + repeat(moduleCount) { + if (offset + 2 > payload.size) return null + val moduleIndex = payload[offset].toInt() and 0xFF + if (!seenModules.add(moduleIndex)) return null + val channelCount = payload[offset + 1].toInt() and 0xFF + offset += 2 + val channels = ArrayList(channelCount) + val seenChannels = HashSet(channelCount) + repeat(channelCount) { + if (offset + 5 > payload.size) return null + val channelIndex = payload[offset].toInt() and 0xFF + if (!seenChannels.add(channelIndex)) return null + val integerRaw = (payload[offset + 1].toInt() and 0xFF) or + ((payload[offset + 2].toInt() and 0xFF) shl 8) + val decimal = (payload[offset + 3].toInt() and 0xFF) or + ((payload[offset + 4].toInt() and 0xFF) shl 8) + val notConnected = integerRaw == 0xFFFF + val value = if (notConnected) null else { + val magnitude = (integerRaw and 0x0FFF) + decimal / 100.0 + if ((integerRaw and 0xF000) != 0) -magnitude else magnitude + } + channels += SensorChannel(channelIndex, value, notConnected) + offset += 5 + } + modules += SensorModule(moduleIndex, channels) + } + if (offset != payload.size) return null + return modules + } + + /** + * PC `ProcessXCPMeasurementData`와 동일한 D0 payload를 검증합니다. + * `[count:2, big-endian][type:1][value:N]...` + */ + fun parseXcpStream(payload: ByteArray): XcpStream? { + if (payload.size < 2) return null + val itemCount = ((payload[0].toInt() and 0xFF) shl 8) or + (payload[1].toInt() and 0xFF) + var offset = 2 + val values = ArrayList(itemCount) + repeat(itemCount) { + if (offset >= payload.size) return null + val typeCode = payload[offset++].toInt() and 0xFF + val dataType = A2lDataType.entries.firstOrNull { it.code == typeCode } ?: return null + if (offset + dataType.byteSize > payload.size) return null + values += XcpValue( + dataType = dataType, + data = payload.copyOfRange(offset, offset + dataType.byteSize) + ) + offset += dataType.byteSize + } + if (offset != payload.size) return null + return XcpStream(values) + } +} + +/** DBC @1(Intel) 및 @0(Motorola) bit field를 최대 64비트까지 추출합니다. */ +object CanSignalBitDecoder { + fun decode( + data: ByteArray, + startBit: Int, + bitLength: Int, + isMotorola: Boolean, + isSigned: Boolean + ): Long? { + if (startBit < 0 || bitLength !in 1..64) return null + var raw = 0L + if (!isMotorola) { + if (startBit + bitLength > data.size * 8) return null + for (bit in 0 until bitLength) { + val absoluteBit = startBit + bit + val value = (data[absoluteBit / 8].toInt() ushr (absoluteBit % 8)) and 1 + raw = raw or (value.toLong() shl bit) + } + } else { + var dbcBit = startBit + for (bit in 0 until bitLength) { + val byteIndex = dbcBit / 8 + val bitIndex = dbcBit % 8 + if (byteIndex !in data.indices) return null + raw = (raw shl 1) or ((data[byteIndex].toInt() ushr bitIndex) and 1).toLong() + dbcBit = if (bitIndex == 0) dbcBit + 15 else dbcBit - 1 + } + } + if (isSigned && bitLength < 64 && (raw and (1L shl (bitLength - 1))) != 0L) { + raw = raw or (-1L shl bitLength) + } + return raw + } +} + +/** 한 CAN 프레임에서 일반/멀티플렉스 DBC 신호를 선택하고 물리값으로 변환합니다. */ +object CanFrameSignalDecoder { + data class DecodedSignal( + val definition: SignalDefinition, + val rawValue: Long, + val physicalValue: Double + ) + + fun decode(data: ByteArray, signals: List): List { + if (data.isEmpty() || signals.isEmpty()) return emptyList() + + val multiplexor = signals.firstOrNull { it.multiplexRole == CanMultiplexRole.MULTIPLEXOR } + val multiplexValue = multiplexor?.let { signal -> + CanSignalBitDecoder.decode( + data = data, + startBit = signal.startBit, + bitLength = signal.bitLength, + isMotorola = signal.isMotorola, + isSigned = false + ) + } + + return buildList(signals.size) { + for (signal in signals) { + if (signal.multiplexRole == CanMultiplexRole.MULTIPLEXED && + (multiplexValue == null || signal.multiplexValue != multiplexValue) + ) { + continue + } + val raw = CanSignalBitDecoder.decode( + data = data, + startBit = signal.startBit, + bitLength = signal.bitLength, + isMotorola = signal.isMotorola, + isSigned = signal.attr.isSigned + ) ?: continue + val numericRaw = if (!signal.attr.isSigned && signal.bitLength == 64 && raw < 0) { + (raw and Long.MAX_VALUE).toDouble() + 9_223_372_036_854_775_808.0 + } else { + raw.toDouble() + } + add(DecodedSignal(signal, raw, numericRaw * signal.cvsA + signal.cvsB)) + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/MdbRecordDecoder.kt b/Android/app/src/main/java/com/dct/app/data/protocol/MdbRecordDecoder.kt new file mode 100644 index 0000000..1eadf1c --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/MdbRecordDecoder.kt @@ -0,0 +1,78 @@ +package com.dct.app.data.protocol + +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import java.util.Locale + +/** MDB 한 레코드를 부분 적용 없이 검증하고 디코딩합니다. */ +object MdbRecordDecoder { + sealed interface DecodeResult { + data class Success(val signals: List) : DecodeResult + data class Failure(val reason: String) : DecodeResult + } + + data class DecodedSignal( + val definition: SignalDefinition, + val rawValue: Number, + val physicalValue: Double + ) + + fun decode(recordId: Int, data: ByteArray, definitions: List): DecodeResult { + val recordSignals = definitions.filter { (it.reclid and 0xFF) == recordId } + .sortedBy { it.arrayIndex } + if (recordSignals.isEmpty()) { + return DecodeResult.Failure("정의되지 않은 MDB ReclID $recordId") + } + if (recordSignals.map { it.idx and 0xFF }.distinct().size != recordSignals.size) { + return DecodeResult.Failure("MDB ReclID $recordId 내 신호 Index가 중복됩니다.") + } + if (recordSignals.map { it.name.lowercase(Locale.ROOT) }.distinct().size != recordSignals.size) { + return DecodeResult.Failure("MDB ReclID $recordId 내 신호 이름이 중복됩니다.") + } + + val expectedSize = recordSignals.maxOf { signal -> + signal.arrayIndex + signal.attr.byteSize + } + if (recordSignals.any { it.arrayIndex < 0 } || expectedSize > data.size) { + return DecodeResult.Failure( + "MDB ReclID $recordId 데이터가 부족합니다. 필요=$expectedSize, 수신=${data.size}" + ) + } + + val occupied = BooleanArray(expectedSize) + for (signal in recordSignals) { + for (offset in signal.arrayIndex until signal.arrayIndex + signal.attr.byteSize) { + if (occupied[offset]) { + return DecodeResult.Failure("MDB ReclID $recordId 신호 바이트 범위가 중복됩니다.") + } + occupied[offset] = true + } + } + + val decoded = ArrayList(recordSignals.size) + for (signal in recordSignals) { + val offset = signal.arrayIndex + val raw: Number = when (signal.attr) { + MdbAttrType.ATTR_0_UINT8 -> data[offset].toInt() and 0xFF + MdbAttrType.ATTR_1_INT8 -> data[offset] + MdbAttrType.ATTR_2_UINT16 -> + (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) + MdbAttrType.ATTR_3_INT16 -> + ((data[offset].toInt() and 0xFF) or + ((data[offset + 1].toInt() and 0xFF) shl 8)).toShort() + MdbAttrType.ATTR_4_UINT32 -> + (data[offset].toLong() and 0xFF) or + ((data[offset + 1].toLong() and 0xFF) shl 8) or + ((data[offset + 2].toLong() and 0xFF) shl 16) or + ((data[offset + 3].toLong() and 0xFF) shl 24) + MdbAttrType.ATTR_5_INT32 -> + (data[offset].toInt() and 0xFF) or + ((data[offset + 1].toInt() and 0xFF) shl 8) or + ((data[offset + 2].toInt() and 0xFF) shl 16) or + ((data[offset + 3].toInt() and 0xFF) shl 24) + } + decoded += DecodedSignal(signal, raw, signal.calculatePhysicalValue(raw)) + } + return DecodeResult.Success(decoded) + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/PacketParser.kt b/Android/app/src/main/java/com/dct/app/data/protocol/PacketParser.kt new file mode 100644 index 0000000..b26729f --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/PacketParser.kt @@ -0,0 +1,29 @@ +package com.dct.app.data.protocol + +import com.dct.app.domain.model.protocol.DctPacket + +/** + * 연속된 TCP 또는 시리얼 바이트 스트림으로부터 바이너리 패킷을 추출하는 파서 인터페이스. + */ +interface PacketParser { + /** + * 수신된 바이트 청크를 입력받아 완성된 [DctPacket] 목록을 파싱하여 반환합니다. + * + * @param buffer 수신된 바이트 배열 + * @param length 처리할 바이트 수 (기본 buffer.size) + * @return 완성된 패킷 리스트 (조각 패킷인 경우 비어있을 수 있음) + */ + fun parseStream(buffer: ByteArray, length: Int = buffer.size): List + + /** + * 단일 패킷을 큐에서 꺼냅니다. (큐 방식 폴링 지원) + * + * @return 파싱된 패킷이 있으면 [DctPacket], 없으면 null + */ + fun pollPacket(): DctPacket? + + /** + * 파서의 내부 상태 및 링 버퍼를 초기화합니다. + */ + fun reset() +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt b/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt new file mode 100644 index 0000000..ec112ac --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt @@ -0,0 +1,207 @@ +package com.dct.app.data.protocol + +import com.dct.app.domain.model.protocol.DctCommand +import com.dct.app.domain.model.protocol.DctPacket +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * TCP 스트림 및 시리얼 데이터 수신을 위한 고성능 슬라이딩 링 버퍼 기반 패킷 파서. + * + * Peek 기반 슬라이딩 윈도우 스캔 및 백트래킹(Backtracking) 롤백 아키텍처: + * 1. 패킷 조각화(Fragmentation): 여러 번에 나누어 도착하는 패킷 완벽 조립. + * 2. 복수 패킷 연속 수신(Coalescing): 한 번의 수신 버퍼에 포함된 다중 패킷 순차 분리. + * 3. 헤더 앞 가비지 바이트 자동 스킵: 0xA1 헤더를 찾을 때까지 가비지 자동 제거. + * 4. 가짜 헤더(0xA1 노이즈) 백트래킹: 가짜 0xA1로 인한 프레이밍 실패 시 0xA1+1 위치로 안전 백트래킹하여 후속 패킷 유실 방지. + * 5. 페이로드 내부 0x7E / 0xA1 데이터 처리: 길이 검증을 통해 페이로드 데이터 바이트와 실제 푸터를 정확히 구분. + * 6. 체크섬 오류 패킷 안전 폐기 및 다음 패킷 복구. + * 7. 링 버퍼 오버플로우 방지 및 자동 복구. + * 8. Thread-Safe: 모든 주요 연산에 synchronized 적용. + */ +class SlidingRingBufferParser( + private val bufferCapacity: Int = DEFAULT_BUFFER_CAPACITY, + private val maxPacketSize: Int = DEFAULT_MAX_PACKET_SIZE, + private val codec: DctPacketCodec = DctPacketCodec() +) : PacketParser { + + companion object { + const val DEFAULT_BUFFER_CAPACITY: Int = 65536 // 64 KB + const val DEFAULT_MAX_PACKET_SIZE: Int = 8192 // 8 KB + } + + private val ringBuffer = ByteArray(bufferCapacity) + private var head = 0 + private var tail = 0 + private var availableBytes = 0 + + private val packetQueue = ConcurrentLinkedQueue() + private var sequenceCounter = 0 + + @Synchronized + override fun parseStream(buffer: ByteArray, length: Int): List { + val result = mutableListOf() + if (length <= 0) return result + + // 1. 링 버퍼에 수신 바이트 적재 + writeToRingBuffer(buffer, 0, length) + + // 2. Peek-based Sliding Window Scan & Backtracking Loop + while (availableBytes > 0) { + // 2.1 현재 버퍼 내에서 START_BYTE (0xA1) 헤더의 오프셋 탐색 + var headerOffset = -1 + for (i in 0 until availableBytes) { + val idx = if (tail + i < bufferCapacity) tail + i else tail + i - bufferCapacity + if (ringBuffer[idx] == DctPacketCodec.START_BYTE) { + headerOffset = i + break + } + } + + // START_BYTE가 없으면 버퍼 내 전체 데이터가 가비지이므로 폐기 후 종료 + if (headerOffset == -1) { + skipBytes(availableBytes) + break + } + + // START_BYTE 앞쪽의 가비지 바이트 폐기 (tail을 0xA1 위치로 이동) + if (headerOffset > 0) { + skipBytes(headerOffset) + } + + // 2.2 최소 헤더 길이 확인: 0xA1(1) + LenH(1) + LenL(1) + CMD(1) = 4바이트 이상 필요 + if (availableBytes < 4) { + // 길이 및 커맨드 정보를 파악하기에 데이터가 부족하므로 추가 수신 대기 + break + } + + val lenH = peek(1).toInt() and 0xFF + val lenL = peek(2).toInt() and 0xFF + val packLen = (lenH shl 8) or lenL + val expectedTotalLength = packLen + DctPacketCodec.OVERHEAD_LENGTH // packLen + 5 + + val cmdByte = peek(3) + if (DctCommand.fromByte(cmdByte) == null) { + // 커맨드가 유효하지 않은 가짜 0xA1 헤더 노이즈 -> 1바이트 스킵 + skipBytes(1) + continue + } + + // 2.3 길이 무결성 검증 (packLen >= 1, MIN_PACKET_LENGTH <= expectedTotalLength <= maxPacketSize) + if (packLen < 1 || expectedTotalLength < DctPacketCodec.MIN_PACKET_LENGTH || expectedTotalLength > maxPacketSize) { + // 잘못된 길이 정보이므로 현재 0xA1은 가짜 헤더(노이즈)임! + // 1바이트만 스킵(백트래킹)하고 0xA1 다음 바이트부터 다시 재탐색 + skipBytes(1) + continue + } + + // 2.4 전체 프레임 수신 여부 확인 + if (availableBytes < expectedTotalLength) { + // 프레임 전체가 아직 도착하지 않았으므로 추가 데이터 수신 대기 + break + } + + // 2.5 푸터 바이트(0x7E) 일치 여부 검증 (Peek) + val footerByte = peek(expectedTotalLength - 1) + if (footerByte != DctPacketCodec.END_BYTE) { + // 푸터가 0x7E가 아니면 가짜 헤더로 판정 -> 1바이트만 스킵하고 백트래킹 + skipBytes(1) + continue + } + + // 2.6 프레임 바이트 추출 및 디코딩/체크섬 검증 + val frameBytes = ByteArray(expectedTotalLength) + peekBytes(frameBytes, 0, expectedTotalLength) + + val decoded = codec.decode( + frame = frameBytes, + offset = 0, + length = expectedTotalLength, + sequence = sequenceCounter + ) + + if (decoded != null) { + // 디코딩 및 체크섬 검증 성공 -> 실제 프레임 길이만큼 링버퍼에서 소비 + skipBytes(expectedTotalLength) + sequenceCounter++ + result.add(decoded) + packetQueue.offer(decoded) + } else { + // 체크섬 검증 실패 등 -> 가짜 헤더로 판정하고 1바이트만 스킵하여 백트래킹 + skipBytes(1) + } + } + + return result + } + + @Synchronized + override fun pollPacket(): DctPacket? { + return packetQueue.poll() + } + + @Synchronized + override fun reset() { + head = 0 + tail = 0 + availableBytes = 0 + packetQueue.clear() + sequenceCounter = 0 + } + + val availableByteCount: Int + @Synchronized get() = availableBytes + + val queuedPacketCount: Int + get() = packetQueue.size + + private fun writeToRingBuffer(data: ByteArray, offset: Int, length: Int) { + if (length <= 0) return + + // 버퍼 용량보다 더 큰 데이터가 한 번에 들어온 경우 최신 데이터만 유지 + if (length >= bufferCapacity) { + val startOffset = offset + (length - bufferCapacity) + System.arraycopy(data, startOffset, ringBuffer, 0, bufferCapacity) + head = 0 + tail = 0 + availableBytes = bufferCapacity + return + } + + // 버퍼 오버플로우 방지: 초과분만큼 tail을 이동하여 가장 오래된 데이터 폐기 + val overflow = (availableBytes + length) - bufferCapacity + if (overflow > 0) { + skipBytes(overflow) + } + + val firstChunk = (bufferCapacity - head).coerceAtMost(length) + System.arraycopy(data, offset, ringBuffer, head, firstChunk) + head = (head + firstChunk) % bufferCapacity + if (length > firstChunk) { + val secondChunk = length - firstChunk + System.arraycopy(data, offset + firstChunk, ringBuffer, 0, secondChunk) + head = secondChunk + } + availableBytes += length + } + + private fun peek(offset: Int): Byte { + check(offset in 0 until availableBytes) { "Invalid peek offset: $offset, available: $availableBytes" } + val index = (tail + offset) % bufferCapacity + return ringBuffer[index] + } + + private fun peekBytes(target: ByteArray, targetOffset: Int, count: Int) { + check(count <= availableBytes) { "Cannot peek $count bytes, only $availableBytes available" } + val firstChunk = (bufferCapacity - tail).coerceAtMost(count) + System.arraycopy(ringBuffer, tail, target, targetOffset, firstChunk) + if (count > firstChunk) { + val secondChunk = count - firstChunk + System.arraycopy(ringBuffer, 0, target, targetOffset + firstChunk, secondChunk) + } + } + + private fun skipBytes(count: Int) { + val toSkip = count.coerceAtMost(availableBytes) + tail = (tail + toSkip) % bufferCapacity + availableBytes -= toSkip + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/XcpValueCodec.kt b/Android/app/src/main/java/com/dct/app/data/protocol/XcpValueCodec.kt new file mode 100644 index 0000000..4496f3d --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/protocol/XcpValueCodec.kt @@ -0,0 +1,41 @@ +package com.dct.app.data.protocol + +import com.dct.app.domain.model.a2l.A2lDataType +import kotlin.math.* + +object XcpValueCodec { + fun encode(type: A2lDataType, value: Double, msbFirst: Boolean): ByteArray { + require(value.isFinite()) { "NaN/무한대는 전송할 수 없습니다." } + val raw = when { + type == A2lDataType.FLOAT16_IEEE -> { + require(abs(value) <= 65504.0) { "FLOAT16 범위 초과" } + val sign = if (value.toRawBits() < 0) 0x8000 else 0 + val v = abs(value) + val half = if (v < 2.0.pow(-14)) Math.rint(v * 2.0.pow(24)).toInt() + else { + val exponent = floor(log2(v)).toInt() + ((exponent + 15) shl 10) + Math.rint((v / 2.0.pow(exponent) - 1) * 1024).toInt() + } + (sign or half).toLong() + } + type == A2lDataType.FLOAT32_IEEE || type == A2lDataType.FLOAT32_TASKING -> { + require(value.toFloat().isFinite()) { "FLOAT32 범위 초과" } + value.toFloat().toRawBits().toLong() + } + type == A2lDataType.FLOAT64_IEEE -> value.toRawBits() + else -> { + require(value == floor(value)) { "정수 타입에는 정수를 입력하세요." } + val bits = type.byteSize * 8 + // UI currently uses Double: do not silently corrupt 64-bit integers beyond its exact range. + val limit = if (bits == 64) 9_007_199_254_740_991.0 else 2.0.pow(if (type.isSigned) bits - 1 else bits) - 1 + val min = if (!type.isSigned) 0.0 else if (bits == 64) -limit else -2.0.pow(bits - 1) + require(value in min..limit) { "${type.typeName} 안전 표현 범위를 벗어났습니다." } + value.toLong() + } + } + return ByteArray(type.byteSize) { i -> + val shift = if (msbFirst) (type.byteSize - 1 - i) * 8 else i * 8 + (raw ushr shift).toByte() + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt b/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt new file mode 100644 index 0000000..50be6a5 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt @@ -0,0 +1,1041 @@ +package com.dct.app.data.repository + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.dct.app.domain.model.project.DpcProject +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.io.File +import java.io.FileOutputStream +import java.io.InputStreamReader +import java.io.OutputStreamWriter + +/** + * PC 버전 (.dpc JSON) 프로젝트 파일 직렬화 / 역직렬화 및 SAF 영속성 매니저 + */ +object DpcProjectManager { + + /** + * 외부 SAF Uri의 파일을 앱 내부 영구 저장소(`context.filesDir/imported_files/`)로 안전하게 복사 + */ + fun copyUriToInternalStorage(context: Context, uri: Uri, targetFileName: String): File? { + return try { + val destDir = File(context.filesDir, "imported_files").apply { + if (!exists()) mkdirs() + val nomedia = File(this, ".nomedia") + if (!nomedia.exists()) nomedia.createNewFile() + } + val destFile = File(destDir, targetFileName) + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(destFile).use { output -> + input.copyTo(output) + } + } + destFile + } catch (e: Exception) { + null + } + } + + /** + * PC 버전(common_def.h)과 100% 호환되는 8대 표준 서브폴더 목록 + */ + val SUB_FOLDERS = listOf( + "Configuration", + "Data", + "MDB", + "DBC", + "A2L", + "SMC", + "Log", + "Screen" + ) + + /** + * Documents/DCT Work 및 8대 서브폴더(Configuration, Data, MDB, DBC, A2L, SMC, Log, Screen) 일괄 자동 생성 + */ + fun ensureDctWorkDirectories(context: Context? = null): File { + var baseDir: File? = null + try { + val extDocs = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOCUMENTS) + val d = File(extDocs, "DCT Work") + if (d.exists() || d.mkdirs()) { + baseDir = d + } + } catch (_: Exception) {} + + if (baseDir == null || !baseDir.exists()) { + try { + val extFiles = context?.getExternalFilesDir(null) + if (extFiles != null) { + val d = File(extFiles, "Documents/DCT Work") + if (d.exists() || d.mkdirs()) { + baseDir = d + } + } + } catch (_: Exception) {} + } + + if (baseDir == null || !baseDir.exists()) { + val internalDocs = context?.let { File(it.filesDir, "Documents/DCT Work") } ?: File("/storage/emulated/0/Documents/DCT Work") + internalDocs.mkdirs() + baseDir = internalDocs + } + + for (sub in SUB_FOLDERS) { + val subDir = File(baseDir, sub) + if (!subDir.exists()) { + subDir.mkdirs() + } + } + + return baseDir + } + + /** + * 기본 작업 베이스 폴더 경로 반환 (PC의 Documents\DCT Work 에 대응하는 안드로이드 Documents/DCT Work) + */ + fun getDefaultBaseFolder(context: Context? = null): String { + return ensureDctWorkDirectories(context).absolutePath + } + + /** + * 계측 데이터 파일 저장 기본 폴더 경로 반환 (PC의 Documents\DCT Work\Data 에 대응하는 안드로이드 Documents/DCT Work/Data) + */ + fun getDefaultMeasuredFolder(context: Context? = null): String { + val base = ensureDctWorkDirectories(context) + val dataDir = File(base, "Data") + if (!dataDir.exists()) dataDir.mkdirs() + return dataDir.absolutePath + } + + /** + * 프로젝트(.dpc) 설정 파일 저장 기본 폴더 경로 반환 (PC의 Documents\DCT Work\Configuration 에 대응) + */ + fun getDefaultConfigurationFolder(context: Context? = null): String { + val base = ensureDctWorkDirectories(context) + val configDir = File(base, "Configuration") + if (!configDir.exists()) configDir.mkdirs() + return configDir.absolutePath + } + + fun getDefaultSubFolder(subFolderName: String, context: Context? = null): String { + val base = ensureDctWorkDirectories(context) + val dir = File(base, subFolderName) + if (!dir.exists()) dir.mkdirs() + return dir.absolutePath + } + + /** + * 새 프로젝트 생성 시 안드로이드 Documents/DCT Work/Data 경로를 기본값으로 고정한 DpcProject 인스턴스 반환 + */ + fun createDefaultProject(context: Context? = null): DpcProject { + val base = getDefaultBaseFolder(context) + val measured = getDefaultMeasuredFolder(context) + return DpcProject( + projectFileName = "", + baseFolder = base, + measuredFilesFolder = measured, + copySavedFolder = "$base/Data" + ) + } + + fun serializeProjectToJson(project: DpcProject): String { + // 기존 PC 원본 root가 있다면 그것을 복사하여 수정, 없다면 새로 생성 + val root = if (project.rawJsonRoot != null) { + JSONObject(project.rawJsonRoot.toString()) + } else { + JSONObject() + } + + root.put("name", project.projectFileName.ifEmpty { "PROJECT" }) + if (!root.has("version")) root.put("version", "0.0.0.2") + root.put("isave_logfile", if (project.isaveLogfile) 1 else 0) + root.put("cartype", project.vehicleCode) + root.put("maker", project.maker) + root.put("canmode", if (project.canMode == "CAN(HiSpeed)") 1 else 0) + root.put("oldcanmode", if (project.forOldModel) 1 else 0) + root.put("writecsv", if (project.writeCsv) 1 else 0) + root.put("writemf4", if (project.writeMf4) 1 else 0) + val effectiveBase = project.baseFolder.ifEmpty { getDefaultBaseFolder() } + root.put("base_folder", effectiveBase) + root.put("copy_savedfolder", project.copySavedFolder.ifEmpty { if (effectiveBase.isNotBlank()) "$effectiveBase/Data" else "" }) + + val logFolder = project.measuredFilesFolder.ifEmpty { getDefaultMeasuredFolder() } + if (logFolder.isNotBlank()) { + if (effectiveBase.isNotBlank() && logFolder.startsWith(effectiveBase, ignoreCase = true)) { + val rel = logFolder.substring(effectiveBase.length) + root.put("logfile_path", rel.ifEmpty { "/Data" }) + root.put("logfile_path_use_basefolder", 1) + } else if (logFolder == "\\Data" || logFolder == "/Data" || logFolder == "Data" || logFolder.startsWith("\\") || logFolder.startsWith("/")) { + root.put("logfile_path", if (logFolder.startsWith("/")) logFolder else if (logFolder.startsWith("\\")) logFolder.replace('\\', '/') else "/$logFolder") + root.put("logfile_path_use_basefolder", 1) + } else { + root.put("logfile_path", logFolder) + root.put("logfile_path_use_basefolder", 0) + } + } else { + root.put("logfile_path", "/Data") + root.put("logfile_path_use_basefolder", 1) + } + root.put("autostart_measuring_recording", if (project.autostartMeasuringRecording) 1 else 0) + + // DID_INFO + val didObj = JSONObject() + didObj.put("DIDF100PN2", project.didInfo.partName2) + didObj.put("DIDF187PN", project.didInfo.partNumber) + didObj.put("DIDF18BPD", project.didInfo.productDate) + didObj.put("DIDF191HW", project.didInfo.ecuHwVersion) + didObj.put("DIDF193SHW", project.didInfo.supplierHwVersion) + didObj.put("DIDF197SN", project.didInfo.systemName) + didObj.put("DIDF1A1SC", project.didInfo.supplierCode) + didObj.put("DIDPreSetting", project.didInfo.preSetting) + root.put("DID_INFO", didObj) + + // hw_device + val hw = if (root.has("hw_device")) JSONObject(root.getJSONObject("hw_device").toString()) else JSONObject() + if (!hw.has("com_port")) hw.put("com_port", "") + if (!hw.has("connectedType")) hw.put("connectedType", 1) + if (!hw.has("host_address")) hw.put("host_address", "") + if (!hw.has("macaddr")) hw.put("macaddr", "") + val usedSmCount = project.sensorModules.count { it.isUsed } + hw.put("sm_count", usedSmCount) + root.put("hw_device", hw) + + // sm_property + if (project.sensorModules.isNotEmpty()) { + val smProp = JSONObject() + val smArr = JSONArray() + for (sm in project.sensorModules) { + val smObj = JSONObject() + smObj.put("index", sm.moduleIndex) + smObj.put("is_used", if (sm.isUsed) 1 else 0) + val chArr = JSONArray() + for (ch in sm.channels) { + val chObj = JSONObject() + chObj.put("index", ch.channelIndex) + chObj.put("name", ch.name) + chObj.put("is_voltage", if (ch.isVoltage) 1 else 0) + chObj.put("unit", ch.unit) + chObj.put("description", ch.description) + chObj.put("color", ch.color) + chArr.put(chObj) + } + smObj.put("channels", chArr) + smArr.put(smObj) + } + smProp.put("modules", smArr) + root.put("sm_property", smProp) + } + + // ask_info: PC EnvSetting 키 이름과 형식을 그대로 유지한다. + val ask = if (root.has("ask_info")) JSONObject(root.getJSONObject("ask_info").toString()) else JSONObject() + ask.put("ASKFWAddr", project.askInfo.firmwareAddress) + ask.put("ASKSTMin", project.askInfo.stMin.coerceIn(0, 0xFF)) + ask.put("seedKeyPath", project.askInfo.seedKeyPath) + ask.put("DependROM", project.askInfo.dependsOnRom) + root.put("ask_info", ask) + + // ota_info + if (!root.has("ota_info")) { + val ota = JSONObject() + val otaItem = JSONObject() + val itemsArr = JSONArray() + for (i in 0 until 8) { + val it = JSONObject() + it.put("OTAAreaAAddress", "") + it.put("OTAAreaASize", 0) + it.put("OTAAreaBAddress", "") + it.put("OTAAreaBSize", 0) + itemsArr.put(it) + } + otaItem.put("item", itemsArr) + ota.put("ota_item", otaItem) + root.put("ota_info", ota) + } + + // view_rect + if (!root.has("view_rect")) { + val viewRect = JSONObject() + val vrItems = JSONArray() + val mainWnd = JSONObject() + mainWnd.put("name", "mainWindow") + mainWnd.put("left", 176) + mainWnd.put("top", 138) + mainWnd.put("right", 1616) + mainWnd.put("bottom", 891) + mainWnd.put("maximized", false) + vrItems.put(mainWnd) + viewRect.put("items", vrItems) + root.put("view_rect", viewRect) + } + + // graphItem_property + val graphProperty = root.optJSONObject("graphItem_property") ?: JSONObject() + graphProperty.put("graph_linear", project.graphLinear) + root.put("graphItem_property", graphProperty) + + // external_file (4 standard entries: mdb, dbc, smf, a2l) + val extFiles = JSONArray() + + // 1. MDB + val mdbObj = JSONObject() + mdbObj.put("ext", "mdb") + val mdbName = project.mdbFileName + val mdbLocal = project.localMdbPath ?: "" + if (mdbName.isNotBlank()) { + if (effectiveBase.isNotBlank() && mdbLocal.startsWith(effectiveBase, ignoreCase = true)) { + val rel = mdbLocal.substring(effectiveBase.length) + mdbObj.put("path", rel) + mdbObj.put("mdbfile_path_use_basefolder", 1) + } else { + mdbObj.put("path", "\\MDB\\$mdbName") + mdbObj.put("mdbfile_path_use_basefolder", 1) + } + mdbObj.put("filename", mdbName) + } else { + mdbObj.put("path", "") + mdbObj.put("mdbfile_path_use_basefolder", 0) + } + if (project.mdbFileUri != null) mdbObj.put("uri", project.mdbFileUri.toString()) + if (!project.localMdbPath.isNullOrBlank()) mdbObj.put("local_path", project.localMdbPath) + extFiles.put(mdbObj) + + // 2. DBC + val dbcObj = JSONObject() + dbcObj.put("ext", "dbc") + val dbcName = project.canDbcFileName + val dbcLocal = project.localCanDbcPath ?: "" + if (dbcName.isNotBlank()) { + if (effectiveBase.isNotBlank() && dbcLocal.startsWith(effectiveBase, ignoreCase = true)) { + val rel = dbcLocal.substring(effectiveBase.length) + dbcObj.put("path", rel) + dbcObj.put("dbcfile_path_use_basefolder", 1) + } else { + dbcObj.put("path", "\\DBC\\$dbcName") + dbcObj.put("dbcfile_path_use_basefolder", 1) + } + dbcObj.put("filename", dbcName) + } else { + dbcObj.put("path", "") + dbcObj.put("dbcfile_path_use_basefolder", 0) + } + if (project.canDbcFileUri != null) dbcObj.put("uri", project.canDbcFileUri.toString()) + if (!project.localCanDbcPath.isNullOrBlank()) dbcObj.put("local_path", project.localCanDbcPath) + extFiles.put(dbcObj) + + // 3. SMF + val smfObj = JSONObject() + smfObj.put("ext", "smf") + smfObj.put("path", "") + smfObj.put("smffile_path_use_basefolder", 0) + extFiles.put(smfObj) + + // 4. A2L + val a2lObj = JSONObject() + a2lObj.put("ext", "a2l") + val a2lName = project.a2lFileName + val a2lLocal = project.localA2lPath ?: "" + if (a2lName.isNotBlank()) { + if (effectiveBase.isNotBlank() && a2lLocal.startsWith(effectiveBase, ignoreCase = true)) { + val rel = a2lLocal.substring(effectiveBase.length) + a2lObj.put("path", rel) + a2lObj.put("a2lfile_path_use_basefolder", 1) + } else { + a2lObj.put("path", "\\A2L\\$a2lName") + a2lObj.put("a2lfile_path_use_basefolder", 1) + } + a2lObj.put("filename", a2lName) + } else { + a2lObj.put("path", "") + a2lObj.put("a2lfile_path_use_basefolder", 0) + } + if (project.a2lFileUri != null) a2lObj.put("uri", project.a2lFileUri.toString()) + if (!project.localA2lPath.isNullOrBlank()) a2lObj.put("local_path", project.localA2lPath) + extFiles.put(a2lObj) + + root.put("external_file", extFiles) + + // CAN DBC Property + if (project.canSignals.isNotEmpty() || project.canSignalColors.isNotEmpty() || project.canSignalDescriptions.isNotEmpty()) { + val candbcProp = JSONObject() + val propArr = JSONArray() + val grpObj = JSONObject() + grpObj.put("id", 0) + grpObj.put("name", "CAN Signal") + val itemsArr = JSONArray() + if (project.canSignals.isNotEmpty()) { + for (sig in project.canSignals) { + val it = JSONObject() + it.put("Name", sig.name) + val col = project.canSignalColors[sig.name] ?: sig.color + it.put("USER_COLOR", col) + val desc = project.canSignalDescriptions[sig.name] ?: sig.description + it.put("Descrip", desc) + it.put("item_thickness", 2) + it.put("item_dashstyle", 0) + itemsArr.put(it) + } + } else { + for ((name, col) in project.canSignalColors) { + val it = JSONObject() + it.put("Name", name) + it.put("USER_COLOR", col) + it.put("Descrip", project.canSignalDescriptions[name] ?: "") + it.put("item_thickness", 2) + it.put("item_dashstyle", 0) + itemsArr.put(it) + } + } + grpObj.put("items", itemsArr) + propArr.put(grpObj) + candbcProp.put("property", propArr) + root.put("candbc_property", candbcProp) + } + + // MDB Recv Property + val db = project.mdbDatabase + if (db != null || project.mdbSignalColors.isNotEmpty() || project.mdbSignalDescriptions.isNotEmpty()) { + val mdbRecvProp = JSONObject() + val propArr = JSONArray() + + // 1. MDB Signal Group + val sigGrp = JSONObject() + sigGrp.put("name", "MDB Signal") + val sigItems = JSONArray() + if (db != null) { + for (sig in db.allSignals) { + val it = JSONObject() + it.put("Index", sig.idx) + it.put("Name", sig.name) + it.put("ADDR", sig.adl) + it.put("ATTR", sig.attr.code) + it.put("MIN", sig.min) + it.put("MAX", sig.max) + it.put("Value", 0) + val col = project.mdbSignalColors[sig.name] ?: sig.color + it.put("USER_COLOR", col) + val desc = project.mdbSignalDescriptions[sig.name] ?: sig.description + it.put("Descrip", desc) + it.put("item_thickness", 2) + it.put("item_dashstyle", 0) + sigItems.put(it) + } + } + sigGrp.put("items", sigItems) + propArr.put(sigGrp) + + // 2. Constant Signal Group + val conGrp = JSONObject() + conGrp.put("name", "Constant Signal") + val conItems = JSONArray() + if (db != null) { + for (con in db.constants) { + val it = JSONObject() + it.put("Index", con.idx) + it.put("Name", con.name) + it.put("ADDR", con.addr) + it.put("ATTR", con.attr.code) + it.put("MIN", con.min) + it.put("MAX", con.max) + it.put("Value", con.data) + it.put("USER_COLOR", -1) + it.put("Descrip", con.description) + conItems.put(it) + } + } + conGrp.put("items", conItems) + propArr.put(conGrp) + + mdbRecvProp.put("property", propArr) + root.put("mdb_recv_property", mdbRecvProp) + + // MDB Send Property + val sendProp = JSONObject() + sendProp.put("property", JSONArray()) + root.put("mdb_send_property", sendProp) + + // MDB Compensation A Property + val compA = JSONObject() + compA.put("NAME", "Compensation Type A") + val compAItems = JSONArray() + if (db != null) { + for (map in db.compensationMaps.filter { it.isTypeA }) { + val it = JSONObject() + it.put("XNAME", map.xName) + it.put("XADDR", map.xAddr) + it.put("XATTR", map.xAttr.code) + it.put("YNAME", map.yName) + it.put("YADDR", map.yAddr) + it.put("YATTR", map.yAttr.code) + it.put("YMIN", map.yMin) + it.put("YMAX", map.yMax) + it.put("CVS_A", map.cvsA) + it.put("CVS_B", map.cvsB) + compAItems.put(it) + } + } + compA.put("items", compAItems) + root.put("mdb_compensation_A_property", compA) + + // MDB Compensation B Property + val compB = JSONObject() + compB.put("NAME", "Compensation Type B") + val compBItems = JSONArray() + if (db != null) { + for (map in db.compensationMaps.filter { it.isTypeB }) { + val it = JSONObject() + it.put("XNAME", map.xName) + it.put("XADDR", map.xAddr) + it.put("XATTR", map.xAttr.code) + it.put("YNAME", map.yName) + it.put("YADDR", map.yAddr) + it.put("YATTR", map.yAttr.code) + it.put("YMIN", map.yMin) + it.put("YMAX", map.yMax) + it.put("CVS_A", map.cvsA) + it.put("CVS_B", map.cvsB) + compBItems.put(it) + } + } + compB.put("items", compBItems) + root.put("mdb_compensation_B_property", compB) + } + + // A2L Property + if (project.a2lSignals.isNotEmpty() || project.a2lSignalColors.isNotEmpty() || project.a2lSignalDescriptions.isNotEmpty()) { + val a2lProp = JSONObject() + val measObj = JSONObject() + val itemsArr = JSONArray() + if (project.a2lSignals.isNotEmpty()) { + for (sig in project.a2lSignals) { + val it = JSONObject() + it.put("Name", sig.name) + val col = project.a2lSignalColors[sig.name] ?: sig.color + it.put("USER_COLOR", col) + val desc = project.a2lSignalDescriptions[sig.name] ?: sig.description + it.put("Descrip", desc) + it.put("item_thickness", 2) + it.put("item_dashstyle", 0) + itemsArr.put(it) + } + } else { + for ((name, col) in project.a2lSignalColors) { + val it = JSONObject() + it.put("Name", name) + it.put("USER_COLOR", col) + it.put("Descrip", project.a2lSignalDescriptions[name] ?: "") + it.put("item_thickness", 2) + it.put("item_dashstyle", 0) + itemsArr.put(it) + } + } + measObj.put("items", itemsArr) + a2lProp.put("measurement", measObj) + val charObj = JSONObject() + charObj.put("items", JSONArray()) + a2lProp.put("characteristic", charObj) + root.put("a2l_property", a2lProp) + } + + // Formula Property + if (project.formulas.isNotEmpty()) { + val formulaRoot = JSONObject() + val formulaArr = JSONArray() + for (f in project.formulas) { + val it = JSONObject() + it.put("Name", f.name) + it.put("Expression", f.expression) + it.put("Unit", f.unit) + it.put("Color", f.color) + it.put("IsForSM", if (f.isForSm) 1 else 0) + it.put("Descrip", f.description) + formulaArr.put(it) + } + formulaRoot.put("items", formulaArr) + root.put("formula_info", formulaRoot) + } + + root.put("android_parameter_descriptions", JSONObject(project.parameterDescriptions)) + project.workspace?.let { workspace -> + // Preserve an unsupported future extension rather than silently downgrading it. + if ((root.optJSONObject(DpcWorkspaceJson.KEY)?.optInt("schema_version", 1) ?: 1) <= DpcWorkspaceJson.VERSION) { + root.put(DpcWorkspaceJson.KEY, DpcWorkspaceJson.encode(workspace)) + } + } + return root.toString(3) + } + + fun parseProjectFromJson(jsonStr: String, projectUri: Uri?): DpcProject { + val root = JSONObject(jsonStr) + val name = root.optString("name", "Project") + val carType = root.optString("cartype", "") + val canMode = if (root.optInt("canmode", if (root.optBoolean("canmode", true)) 1 else 0) == 1) "CAN(HiSpeed)" else "CAN(FD)" + val oldCanMode = root.optInt("oldcanmode", if (root.optBoolean("oldcanmode", false)) 1 else 0) == 1 + val writeCsv = root.optInt("writecsv", if (root.optBoolean("writecsv", false)) 1 else 0) == 1 + val writeMf4 = root.optInt("writemf4", if (root.optBoolean("writemf4", false)) 1 else 0) == 1 + val logfilePath = root.optString("logfile_path", "") + val baseFolder = root.optString("base_folder", "") + val copySavedFolder = root.optString("copy_savedfolder", "") + val maker = root.optInt("maker", 2) + val isaveLogfile = root.optInt("isave_logfile", if (root.optBoolean("isave_logfile", true)) 1 else 0) == 1 + val autostartMeasuring = root.optInt("autostart_measuring_recording", if (root.optBoolean("autostart_measuring_recording", false)) 1 else 0) == 1 + + var mdbFileName = "" + var mdbUri: Uri? = null + var localMdbPath: String? = null + var canDbcFileName = "" + var canDbcUri: Uri? = null + var localCanDbcPath: String? = null + var a2lFileName = "" + var a2lUri: Uri? = null + var localA2lPath: String? = null + + val extFiles = root.optJSONArray("external_file") + if (extFiles != null) { + for (i in 0 until extFiles.length()) { + val item = extFiles.optJSONObject(i) ?: continue + val type = item.optString("type", "") + val ext = item.optString("ext", "") + val path = item.optString("path", "") + val uriStr = item.optString("uri", "") + val localPath = item.optString("local_path", "") + val filename = item.optString("filename", "") + val fileType = if (type.isNotBlank()) type.lowercase() else ext.lowercase() + val safePathName = path.replace('\\', '/').substringAfterLast('/') + when (fileType) { + "mdb" -> { + mdbFileName = filename.ifEmpty { safePathName } + if (uriStr.isNotBlank()) mdbUri = Uri.parse(uriStr) + else if (path.startsWith("content://")) mdbUri = Uri.parse(path) + if (localPath.isNotBlank()) localMdbPath = localPath + } + "candbc", "dbc" -> { + canDbcFileName = filename.ifEmpty { safePathName } + if (uriStr.isNotBlank()) canDbcUri = Uri.parse(uriStr) + else if (path.startsWith("content://")) canDbcUri = Uri.parse(path) + if (localPath.isNotBlank()) localCanDbcPath = localPath + } + "a2l" -> { + a2lFileName = filename.ifEmpty { safePathName } + if (uriStr.isNotBlank()) a2lUri = Uri.parse(uriStr) + else if (path.startsWith("content://")) a2lUri = Uri.parse(path) + if (localPath.isNotBlank()) localA2lPath = localPath + } + } + } + } + + // CAN DBC 신호별 USER_COLOR 및 Descrip 추출 + val canSignalColors = mutableMapOf() + val canSignalDescriptions = mutableMapOf() + val candbcProp = root.optJSONObject("candbc_property") + if (candbcProp != null) { + val propArray = candbcProp.optJSONArray("property") + if (propArray != null) { + for (p in 0 until propArray.length()) { + val groupObj = propArray.optJSONObject(p) ?: continue + val items = groupObj.optJSONArray("items") ?: groupObj.optJSONArray("item") ?: continue + for (i in 0 until items.length()) { + val it = items.optJSONObject(i) ?: continue + val sigName = it.optString("Name", "") + val userColor = it.optLong("USER_COLOR", -1L) + val desc = it.optString("Descrip", "") + if (sigName.isNotBlank()) { + if (userColor >= 0L) { + canSignalColors[sigName] = userColor + } + if (desc.isNotBlank()) { + canSignalDescriptions[sigName] = desc + } + } + } + } + } + } + + // MDB 수신 신호별 USER_COLOR 및 Descrip 추출 + val mdbSignalColors = mutableMapOf() + val mdbSignalDescriptions = mutableMapOf() + val mdbRecvProp = root.optJSONObject("mdb_recv_property") + if (mdbRecvProp != null) { + val propArray = mdbRecvProp.optJSONArray("property") + if (propArray != null) { + for (p in 0 until propArray.length()) { + val groupObj = propArray.optJSONObject(p) ?: continue + val items = groupObj.optJSONArray("items") ?: groupObj.optJSONArray("item") ?: continue + for (i in 0 until items.length()) { + val it = items.optJSONObject(i) ?: continue + val sigName = it.optString("Name", "") + val userColor = it.optLong("USER_COLOR", -1L) + val desc = it.optString("Descrip", "") + if (sigName.isNotBlank()) { + if (userColor >= 0L) { + mdbSignalColors[sigName] = userColor + } + if (desc.isNotBlank()) { + mdbSignalDescriptions[sigName] = desc + } + } + } + } + } + } + + // A2L 측정 신호별 USER_COLOR 및 Descrip 추출 + val a2lSignalColors = mutableMapOf() + val a2lSignalDescriptions = mutableMapOf() + val a2lProp = root.optJSONObject("a2l_property") + if (a2lProp != null) { + val measObj = a2lProp.optJSONObject("measurement") + if (measObj != null) { + val items = measObj.optJSONArray("items") ?: measObj.optJSONArray("item") + if (items != null) { + for (i in 0 until items.length()) { + val it = items.optJSONObject(i) ?: continue + val sigName = it.optString("Name", "") + val userColor = it.optLong("USER_COLOR", -1L) + val desc = it.optString("Descrip", "") + if (sigName.isNotBlank()) { + if (userColor >= 0L) { + a2lSignalColors[sigName] = userColor + } + if (desc.isNotBlank()) { + a2lSignalDescriptions[sigName] = desc + } + } + } + } + } + } + + // DID_INFO 역직렬화 + val didObj = root.optJSONObject("DID_INFO") + val didInfo = if (didObj != null) { + com.dct.app.domain.model.project.DpcDidInfo( + partName2 = didObj.optString("DIDF100PN2", ""), + ecuHwVersion = didObj.optString("DIDF191HW", ""), + partNumber = didObj.optString("DIDF187PN", ""), + productDate = didObj.optString("DIDF18BPD", ""), + supplierCode = didObj.optString("DIDF1A1SC", ""), + systemName = didObj.optString("DIDF197SN", ""), + supplierHwVersion = didObj.optString("DIDF193SHW", ""), + preSetting = didObj.optInt("DIDPreSetting", 49) + ) + } else { + com.dct.app.domain.model.project.DpcDidInfo() + } + + val askObj = root.optJSONObject("ask_info") + val askInfo = if (askObj != null) { + com.dct.app.domain.model.project.DpcAskInfo( + firmwareAddress = askObj.optString("ASKFWAddr", ""), + stMin = askObj.optInt("ASKSTMin", 0).coerceIn(0, 0xFF), + seedKeyPath = askObj.optString("seedKeyPath", ""), + dependsOnRom = askObj.optBoolean("DependROM", false) + ) + } else { + com.dct.app.domain.model.project.DpcAskInfo() + } + + // SM_PROPERTY 역직렬화 + val smList = mutableListOf() + val smProp = root.optJSONObject("sm_property") + if (smProp != null) { + val smArr = smProp.optJSONArray("modules") + if (smArr != null) { + for (i in 0 until smArr.length()) { + val smItem = smArr.optJSONObject(i) ?: continue + val mIdx = smItem.optInt("index", i) + val isUsed = smItem.optInt("is_used", if (smItem.optBoolean("is_used", false)) 1 else 0) == 1 + val chList = mutableListOf() + val chArr = smItem.optJSONArray("channels") + if (chArr != null) { + for (c in 0 until chArr.length()) { + val chItem = chArr.optJSONObject(c) ?: continue + val cIdx = chItem.optInt("index", c) + val cName = chItem.optString("name", "${String.format(java.util.Locale.US, "%02d", mIdx + 1)}-${String.format(java.util.Locale.US, "%02d", cIdx + 1)} ch") + val isVolt = chItem.optInt("is_voltage", if (chItem.optBoolean("is_voltage", false)) 1 else 0) == 1 + val unit = chItem.optString("unit", if (isVolt) "V" else "'C") + val desc = chItem.optString("description", "") + val color = chItem.optLong("color", 0xFF009A4CL) + chList.add(com.dct.app.domain.model.project.DpcSensorModuleChannel(cIdx, cName, isVolt, unit, desc, color)) + } + } + if (chList.isEmpty()) { + chList.addAll(com.dct.app.domain.model.project.createDefaultSmChannels(mIdx)) + } + smList.add(com.dct.app.domain.model.project.DpcSensorModuleConfig(mIdx, isUsed, chList)) + } + } + } + if (smList.isEmpty()) { + smList.addAll(com.dct.app.domain.model.project.createDefaultSensorModules()) + } + + // Formula Parsing + val formulaList = mutableListOf() + if (root.has("formula_info")) { + val fInfo = root.optJSONObject("formula_info") + val fItems = fInfo?.optJSONArray("items") + if (fItems != null) { + for (i in 0 until fItems.length()) { + val it = fItems.optJSONObject(i) ?: continue + val fName = it.optString("Name", "") + val fExpr = it.optString("Expression", "") + if (fName.isNotBlank() && fExpr.isNotBlank()) { + formulaList.add( + com.dct.app.domain.model.formula.FormulaDefinition( + name = fName, + expression = fExpr, + unit = it.optString("Unit", ""), + color = it.optLong("Color", 0xFF2196F3), + isForSm = it.optInt("IsForSM", 0) == 1, + description = it.optString("Descrip", "") + ) + ) + } + } + } + } + + return DpcProject( + vehicleCode = carType, + projectFileName = name, + projectFileUri = projectUri, + mdbFileName = mdbFileName, + mdbFileUri = mdbUri, + localMdbPath = localMdbPath, + a2lFileName = a2lFileName, + a2lFileUri = a2lUri, + localA2lPath = localA2lPath, + canDbcFileName = canDbcFileName, + canDbcFileUri = canDbcUri, + localCanDbcPath = localCanDbcPath, + measuredFilesFolder = logfilePath, + writeCsv = writeCsv, + writeMf4 = writeMf4, + canMode = canMode, + forOldModel = oldCanMode, + didInfo = didInfo, + askInfo = askInfo, + sensorModules = smList, + baseFolder = baseFolder, + copySavedFolder = copySavedFolder, + maker = maker, + isaveLogfile = isaveLogfile, + autostartMeasuringRecording = autostartMeasuring, + canSignalColors = canSignalColors, + canSignalDescriptions = canSignalDescriptions, + mdbSignalColors = mdbSignalColors, + mdbSignalDescriptions = mdbSignalDescriptions, + a2lSignalColors = a2lSignalColors, + a2lSignalDescriptions = a2lSignalDescriptions, + formulas = formulaList, + rawJsonRoot = root, + workspace = DpcWorkspaceJson.decode(root.optJSONObject(DpcWorkspaceJson.KEY)), + parameterDescriptions = root.optJSONObject("android_parameter_descriptions")?.let { obj -> + obj.keys().asSequence().take(4096).filter { obj.opt(it) is String }.associateWith { obj.getString(it) } + } ?: emptyMap(), + graphLinear = root.optJSONObject("graphItem_property")?.optBoolean("graph_linear", true) ?: true + ) + } + + fun saveProject(context: Context, project: DpcProject): Boolean { + ensureDctWorkDirectories(context) + val json = serializeProjectToJson(project) + + val rawName = project.projectFileName.ifEmpty { + project.mdbFileName.ifEmpty { + project.a2lFileName.ifEmpty { "PROJECT" } + } + } + val fileName = if (rawName.endsWith(".dpc", ignoreCase = true)) rawName else "$rawName.dpc" + + var isLocalSaved = false + + // 1. 1차 기본 Configuration 폴더(Documents/DCT Work/Configuration)에 저장 + try { + val configDir = File(getDefaultConfigurationFolder(context)) + if (!configDir.exists()) configDir.mkdirs() + + val targetFile = File(configDir, fileName) + targetFile.writeText(json, Charsets.UTF_8) + isLocalSaved = true + } catch (e: Exception) { + e.printStackTrace() + } + + // 2. 만약 1차 저장 실패 시 (Android Scoped Storage 권한 등) 외부 앱 전용/내부 저장소로 폴백 저장 + if (!isLocalSaved) { + try { + val fallbackDir = context.getExternalFilesDir(null)?.let { File(it, "Documents/DCT Work/Configuration") } + ?: File(context.filesDir, "Documents/DCT Work/Configuration") + if (!fallbackDir.exists()) fallbackDir.mkdirs() + + val fallbackFile = File(fallbackDir, fileName) + fallbackFile.writeText(json, Charsets.UTF_8) + isLocalSaved = true + } catch (e: Exception) { + e.printStackTrace() + } + } + + // 3. 외부 SAF Uri가 지정되어 있는 경우 동기화 내보내기 + val uri = project.projectFileUri + if (uri != null) { + try { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + } catch (_: Exception) {} + + try { + context.contentResolver.openOutputStream(uri, "wt")?.use { os -> + OutputStreamWriter(os, Charsets.UTF_8).use { writer -> + writer.write(json) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + return isLocalSaved + } + + fun loadProject(context: Context, uri: Uri): DpcProject? { + try { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + } catch (_: Exception) {} + + return try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + val text = com.dct.app.core.util.TextStreamDecoder.readStringSmart(inputStream) + parseProjectFromJson(text, uri) + } + } catch (e: Exception) { + null + } + } + + /** + * 로컬 파일 시스템(.dpc)에서 프로젝트 로드 + */ + fun loadProjectFromFile(file: File): DpcProject? { + return try { + if (!file.exists()) return null + val text = com.dct.app.core.util.TextStreamDecoder.decodeBytesSmart(file.readBytes()) + parseProjectFromJson(text, null).copy( + projectFileName = file.name, + localProjectPath = file.absolutePath + ) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Documents/DCT Work/Configuration 및 Documents/DCT Work 에 저장된 .dpc 프로젝트 파일 목록 조회 + */ + fun getSavedProjectsInConfiguration(context: Context? = null): List { + val list = mutableListOf() + val searchedDirs = mutableListOf() + + try { + searchedDirs.add(File(getDefaultConfigurationFolder(context))) + searchedDirs.add(File(getDefaultBaseFolder(context))) + + context?.getExternalFilesDir(null)?.let { + searchedDirs.add(File(it, "Documents/DCT Work/Configuration")) + searchedDirs.add(File(it, "Documents/DCT Work")) + } + if (context != null) { + searchedDirs.add(File(context.filesDir, "Documents/DCT Work/Configuration")) + searchedDirs.add(File(context.filesDir, "Documents/DCT Work")) + } + + for (dir in searchedDirs) { + if (dir.exists()) { + val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".dpc", ignoreCase = true) } + if (files != null) { + for (f in files) { + if (list.none { it.name.equals(f.name, ignoreCase = true) }) { + list.add(f) + } + } + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return list.sortedByDescending { it.lastModified() } + } + + /** + * 특정 확장자 및 서브폴더에 저장된 파일 목록 조회 (예: .mdb, .a2l, .dbc) + */ + fun getFilesByExtension(extension: String, subFolder: String, context: Context? = null): List { + val list = mutableListOf() + val searchedDirs = mutableListOf() + + try { + searchedDirs.add(File(getDefaultSubFolder(subFolder, context))) + searchedDirs.add(File(getDefaultBaseFolder(context))) + + context?.getExternalFilesDir(null)?.let { + searchedDirs.add(File(it, "Documents/DCT Work/$subFolder")) + searchedDirs.add(File(it, "Documents/DCT Work")) + } + if (context != null) { + searchedDirs.add(File(context.filesDir, "Documents/DCT Work/$subFolder")) + searchedDirs.add(File(context.filesDir, "imported_files")) + searchedDirs.add(File(context.filesDir, "Documents/DCT Work")) + } + + val extWithDot = if (extension.startsWith(".")) extension else ".$extension" + for (dir in searchedDirs) { + if (dir.exists()) { + val files = dir.listFiles { f -> f.isFile && f.name.endsWith(extWithDot, ignoreCase = true) } + if (files != null) { + for (f in files) { + if (list.none { it.name.equals(f.name, ignoreCase = true) }) { + list.add(f) + } + } + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return list.sortedByDescending { it.lastModified() } + } + + /** + * Configuration 폴더에 동일한 이름의 .dpc 파일이 이미 존재하는지 확인 + */ + fun isProjectFileExists(context: Context? = null, fileName: String): Boolean { + if (fileName.isBlank()) return false + val rawName = if (fileName.endsWith(".dpc", ignoreCase = true)) fileName else "$fileName.dpc" + try { + val configDir = File(getDefaultConfigurationFolder(context)) + val targetFile = File(configDir, rawName) + if (targetFile.exists() && targetFile.length() > 0) return true + + val fallbackDir = context?.getExternalFilesDir(null)?.let { File(it, "Documents/DCT Work/Configuration") } + ?: context?.filesDir?.let { File(it, "Documents/DCT Work/Configuration") } + if (fallbackDir != null) { + val fallbackFile = File(fallbackDir, rawName) + if (fallbackFile.exists() && fallbackFile.length() > 0) return true + } + } catch (e: Exception) { + e.printStackTrace() + } + return false + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/repository/DpcWorkspaceJson.kt b/Android/app/src/main/java/com/dct/app/data/repository/DpcWorkspaceJson.kt new file mode 100644 index 0000000..675eb47 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/repository/DpcWorkspaceJson.kt @@ -0,0 +1,141 @@ +package com.dct.app.data.repository + +import com.dct.app.domain.model.chart.* +import com.dct.app.domain.model.project.* +import org.json.JSONArray +import org.json.JSONObject + +/** Android extension only; PC's opaque MFC layout is never rewritten here. */ +object DpcWorkspaceJson { + const val KEY = "android_workspace" + const val VERSION = 1 + + fun encode(state: DpcWorkspaceState): JSONObject = JSONObject() + .put("schema_version", VERSION) + .put("active_page_id", state.activePageId) + .put("pages", array(state.pages.map { p -> + JSONObject().put("id", p.id).put("title", p.title) + .put("active_window_id", p.activeWindowId) + .put("scroll_x", p.scrollX).put("scroll_y", p.scrollY) + .put("time_window_sec", p.timeWindowSec).put("time_window_preset", p.timeWindowPreset.name) + .put("mdb_search_query", p.mdbSearchQuery).put("can_search_query", p.canSearchQuery) + .put("created_at", p.createdAt) + .put("signals", array(p.signals.map(::encodeSignal))) + .put("alphanumerical_signals", array(p.alphanumericalSignals)) + .put("windows", array(p.windows.map(::encodeWindow))) + })) + + fun decode(root: JSONObject?): DpcWorkspaceState? { + if (root == null || root.optInt("schema_version", 0) != VERSION) return null + val windowIds = mutableSetOf() + val pages = objects(root.optJSONArray("pages"), 100).mapNotNull { p -> + val id = p.optString("id").takeIf { it.isNotBlank() } ?: return@mapNotNull null + val windows = objects(p.optJSONArray("windows"), 100).mapNotNull(::decodeWindow) + .filter { windowIds.add(it.id) } + DpcPageState( + id = id, title = p.optString("title", id), windows = windows, + activeWindowId = p.optString("active_window_id").takeIf { key -> windows.any { it.id == key } }, + scrollX = p.number("scroll_x", 0.0, 0.0, 100000.0).toFloat(), + scrollY = p.number("scroll_y", 0.0, 0.0, 100000.0).toFloat(), + signals = objects(p.optJSONArray("signals")).mapNotNull(::decodeSignal), + alphanumericalSignals = strings(p.optJSONArray("alphanumerical_signals")), + timeWindowSec = p.number("time_window_sec", 10.0, 0.001, 86400.0).toFloat(), + timeWindowPreset = enum(p.optString("time_window_preset"), TimeWindowPreset.SEC_10), + mdbSearchQuery = p.optString("mdb_search_query"), canSearchQuery = p.optString("can_search_query"), + createdAt = p.optLong("created_at", 0) + ) + }.distinctBy { it.id } + if (pages.isEmpty()) return null + val activeId = root.optString("active_page_id").takeIf { key -> pages.any { it.id == key } } ?: pages.first().id + return DpcWorkspaceState(activeId, pages) + } + + private fun encodeWindow(w: DpcWindowState): JSONObject = JSONObject() + .put("id", w.id).put("type", w.type).put("title", w.title) + .put("x", w.x).put("y", w.y).put("width", w.width).put("height", w.height) + .put("z_index", w.zIndex).put("closable", w.isClosable) + .put("minimized", w.isMinimized).put("maximized", w.isMaximized).put("created_at", w.createdAt) + .put("time_window_preset", w.timeWindowPreset.name).put("dashboard_grid_mode", w.dashboardGridMode) + .put("target_table_name", w.targetTableName).put("target_parameter_name", w.targetParameterName) + .put("auto_scroll", w.autoScroll).put("signals", array(w.signals.map(::encodeSignal))) + .put("target_signals", array(w.targetSignals)).put("tile_mode", w.isTileMode) + .put("hidden_signal_ids", array(w.hiddenSignalIds)).put("highlight_colors", JSONObject(w.highlightColors)) + .put("content_state", JSONObject(w.contentState)) + .apply { w.chart?.let { put("chart", encodeChart(it)) } } + + private fun decodeWindow(w: JSONObject): DpcWindowState? { + val id = w.optString("id").takeIf { it.isNotBlank() } ?: return null + val type = w.optString("type").takeIf { it.isNotBlank() } ?: return null + val highlights = w.optJSONObject("highlight_colors") + return DpcWindowState( + id = id, type = type, title = w.optString("title", type), + x = w.number("x", 20.0, 0.0, 100000.0).toFloat(), y = w.number("y", 20.0, 0.0, 100000.0).toFloat(), + width = w.number("width", 360.0, 120.0, 10000.0).toFloat(), height = w.number("height", 300.0, 80.0, 10000.0).toFloat(), + zIndex = w.number("z_index", 0.0, 0.0, 100000.0).toFloat(), + isClosable = w.optBoolean("closable", true), isMinimized = w.optBoolean("minimized", false), + isMaximized = w.optBoolean("maximized", false), createdAt = w.optLong("created_at", 0), + timeWindowPreset = enum(w.optString("time_window_preset"), TimeWindowPreset.SEC_10), + dashboardGridMode = w.optString("dashboard_grid_mode", "GRID_3X3"), + targetTableName = w.optString("target_table_name"), targetParameterName = w.optString("target_parameter_name"), + autoScroll = w.optBoolean("auto_scroll", true), signals = objects(w.optJSONArray("signals")).mapNotNull(::decodeSignal), + targetSignals = strings(w.optJSONArray("target_signals")), isTileMode = w.optBoolean("tile_mode", true), + hiddenSignalIds = strings(w.optJSONArray("hidden_signal_ids")).toSet(), + highlightColors = highlights?.keys()?.asSequence()?.take(4096)?.associateWith { highlights.optLong(it) } ?: emptyMap(), + contentState = w.optJSONObject("content_state")?.let { obj -> + obj.keys().asSequence().take(256).filter { obj.opt(it) is String } + .associateWith { obj.getString(it).take(32768) } + } ?: emptyMap(), + chart = w.optJSONObject("chart")?.let(::decodeChart) + ) + } + + private fun encodeSignal(s: DpcSignalBindingState): JSONObject = JSONObject() + .put("signal_id", s.signalId).put("signal_name", s.signalName).put("axis", s.axisPosition.name) + .put("color", s.color).put("unit", s.unit).put("line_width", s.lineWidthDp) + .put("dash_style", s.dashStyle).put("visible", s.isVisible).put("interpolation", s.interpolationMode.name) + + private fun decodeSignal(s: JSONObject): DpcSignalBindingState? { + val id = s.optString("signal_id").takeIf { it.isNotBlank() } ?: return null + return DpcSignalBindingState( + id, s.optString("signal_name", id), enum(s.optString("axis"), AxisPosition.LEFT_0), + s.optLong("color", 0xFF2196F3L), s.optString("unit"), + s.number("line_width", 2.0, 1.0, 5.0).toFloat(), s.optInt("dash_style", 0).coerceIn(0, 4), + s.optBoolean("visible", true), enum(s.optString("interpolation"), InterpolationMode.LINEAR) + ) + } + + private fun encodeChart(c: DpcChartState): JSONObject = JSONObject() + .put("time_window_sec", c.timeWindowSec).put("time_window_preset", c.timeWindowPreset.name) + .put("auto_time_window", c.isAutoTimeWindow).put("x_axis_auto", c.isXAxisAuto) + .put("y_axis_auto", c.isYAxisAuto).put("legend_position", c.legendPosition) + .put("axes", array(c.axes.map { a -> + JSONObject().put("position", a.position.name).put("min", a.minVal).put("max", a.maxVal) + .put("auto", a.isAuto).put("color", a.color).put("unit", a.unit).put("label", a.label) + .put("tick_count", a.tickCount).put("offset_dp", a.offsetDp).put("visible", a.isVisible) + })) + + private fun decodeChart(c: JSONObject): DpcChartState = DpcChartState( + axes = objects(c.optJSONArray("axes"), 8).mapNotNull { a -> + val position = AxisPosition.entries.find { it.name == a.optString("position") } ?: return@mapNotNull null + val min = a.number("min", -50.0, -1e300, 1e300) + val max = a.number("max", 100.0, -1e300, 1e300).coerceAtLeast(min) + ChartAxisConfig(position, min, max, a.optBoolean("auto", true), a.optLong("color", 0xFF172B4DL), + a.optString("unit"), a.optString("label"), a.optInt("tick_count", 5).coerceIn(2, 100), + a.number("offset_dp", 0.0, 0.0, 1000.0).toFloat(), a.optBoolean("visible", true)) + }.distinctBy { it.position }, + timeWindowSec = c.number("time_window_sec", 30.0, 0.001, 86400.0), + timeWindowPreset = enum(c.optString("time_window_preset"), TimeWindowPreset.SEC_30), + isAutoTimeWindow = c.optBoolean("auto_time_window", false), isXAxisAuto = c.optBoolean("x_axis_auto", true), + isYAxisAuto = c.optBoolean("y_axis_auto", true), legendPosition = c.optInt("legend_position", 0).coerceIn(0, 3) + ) + + private fun array(items: Iterable<*>): JSONArray = JSONArray().apply { items.forEach { put(it) } } + private fun objects(items: JSONArray?, limit: Int = 4096): List = + (0 until minOf(items?.length() ?: 0, limit)).mapNotNull { items?.optJSONObject(it) } + private fun strings(items: JSONArray?): List = + (0 until minOf(items?.length() ?: 0, 4096)).mapNotNull { (items?.opt(it) as? String)?.takeIf(String::isNotBlank) }.distinct() + private fun JSONObject.number(key: String, fallback: Double, min: Double, max: Double): Double = + optDouble(key, fallback).let { if (it.isFinite()) it.coerceIn(min, max) else fallback } + private inline fun > enum(name: String, fallback: T): T = + enumValues().firstOrNull { it.name == name } ?: fallback +} diff --git a/Android/app/src/main/java/com/dct/app/data/repository/MdbRepositoryImpl.kt b/Android/app/src/main/java/com/dct/app/data/repository/MdbRepositoryImpl.kt new file mode 100644 index 0000000..3d33cba --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/repository/MdbRepositoryImpl.kt @@ -0,0 +1,920 @@ +package com.dct.app.data.repository + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import com.healthmarketscience.jackcess.* +import com.dct.app.core.common.DctResult +import com.dct.app.core.common.DispatcherProvider +import com.dct.app.core.error.DctError +import com.dct.app.data.mdb.MdbSchemaParser +import com.dct.app.data.mdb.VirtualMdbGenerator +import com.dct.app.domain.model.mdb.* +import com.dct.app.domain.repository.MdbRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.nio.charset.Charset +import java.text.SimpleDateFormat +import java.util.* + +/** + * Pure Java MS Access (Jackcess 4.0.11) 기반 MDB 레포지토리 구현체 + * + * 주요 아키텍처 원칙: + * 1. Safe Local Cache & Atomic Flush: SAF 스트림 파일 파손 방지를 위한 임시 캐시 및 백업 롤백 지원 + * 2. MS949 인코딩 완벽 지원 (윈도우 한글 MDB 텍스트 무결성 보장) + * 3. C++ LibDB.cpp 100% 호환 도메인 모델 파싱 및 전용 CRUD 엔진 + * 4. DispatcherProvider.io 기반 비동기 스레드 풀 격리 및 Mutex 기반 완전 동시성(Thread-Safety) 보장 + */ +class MdbRepositoryImpl( + private val dispatchers: DispatcherProvider +) : MdbRepository { + + init { + // Android ART 환경 필수 설정 (NIO FileLock 및 스크립트 충돌 방어) + System.setProperty("com.healthmarketscience.jackcess.brokenNio", "true") + } + + private val mutex = Mutex() + private val ms949Charset: Charset = Charset.forName("MS949") + + @Volatile private var currentDb: Database? = null + @Volatile private var currentDbFile: File? = null + @Volatile private var currentOriginalUri: Uri? = null + @Volatile private var currentDisplayName: String = "database.mdb" + @Volatile private var cachedDbInfo: MdbDatabaseInfo? = null + @Volatile private var cachedDomainDb: MdbDatabase? = null + @Volatile private var cachedTableNames: List = emptyList() + + // ========================================================================= + // 1. 도메인 단위 MDB 엔진 API + // ========================================================================= + + override suspend fun loadMdb(inputStream: InputStream): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + closeInternal() + + val tempFile = File.createTempFile("active_session_", ".mdb") + tempFile.deleteOnExit() + + FileOutputStream(tempFile).use { output -> + inputStream.copyTo(output) + } + + val db = DatabaseBuilder.open(tempFile) + + currentDb = db + currentDbFile = tempFile + currentDisplayName = tempFile.name + + val domainDb = MdbSchemaParser.parseDatabase(db, currentDisplayName) + cachedDomainDb = domainDb + cachedDbInfo = buildDbInfo(tempFile, db, currentDisplayName) + cachedTableNames = db.tableNames.filter { !it.startsWith("MSys") && !it.startsWith("~") }.sorted() + + DctResult.Success(domainDb) + } catch (e: Exception) { + DctResult.Failure(DctError.MdbError.CorruptedDatabase(e.message ?: "MDB InputStream 로드 실패"), e) + } + } + } + + override suspend fun loadMdb(file: File): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + closeInternal() + if (!file.exists()) { + return@withLock DctResult.Failure(DctError.MdbError.FileNotFound(file.absolutePath)) + } + + // 원본 파일 보존을 위해 세션 작업용 캐시 파일로 복사 + val sessionFile = File.createTempFile("session_${file.nameWithoutExtension}_", ".mdb") + sessionFile.deleteOnExit() + file.copyTo(sessionFile, overwrite = true) + + val db = DatabaseBuilder.open(sessionFile) + + currentDb = db + currentDbFile = sessionFile + currentDisplayName = file.name + + val domainDb = MdbSchemaParser.parseDatabase(db, file.name) + cachedDomainDb = domainDb + cachedDbInfo = buildDbInfo(sessionFile, db, file.name) + cachedTableNames = db.tableNames.filter { !it.startsWith("MSys") && !it.startsWith("~") }.sorted() + + DctResult.Success(domainDb) + } catch (e: Exception) { + DctResult.Failure(DctError.MdbError.CorruptedDatabase(e.message ?: "MDB 파일 로드 실패"), e) + } + } + } + + override suspend fun saveMdb(database: MdbDatabase, outputStream: OutputStream): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + val file = currentDbFile ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("저장할 활성 세션 DB 파일이 없습니다.")) + currentDb?.flush() + + file.inputStream().use { input -> + input.copyTo(outputStream) + } + outputStream.flush() + + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "MDB 저장 실패"), e) + } + } + } + + override suspend fun updateConstant(name: String, value: Double): DctResult = + updateConstant(name, value, null) + + override suspend fun updateConstant(name: String, value: Double, description: String?): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + val db = currentDb ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("열린 MDB 세션이 없습니다.")) + val table = db.getTable("ConLst") ?: return@withLock DctResult.Failure(DctError.MdbError.TableNotFound("ConLst")) + + val cursor = CursorBuilder.createCursor(table) + var found = false + + while (cursor.moveToNextRow()) { + val row = cursor.currentRow + val cName = row["NAME"]?.toString() ?: row["VAR_NAME"]?.toString() ?: "" + if (cName.equals(name, ignoreCase = true)) { + val updateMap = mutableMapOf() + val dataCol = table.getColumn("DATA") ?: table.getColumn("CURRENT_VAL") + if (dataCol != null) { + updateMap[dataCol.name] = when (dataCol.type) { + DataType.TEXT, DataType.MEMO -> String.format(Locale.US, "%.4f", value) + DataType.INT -> value.toInt().toShort() + DataType.LONG -> value.toInt() + DataType.FLOAT -> value.toFloat() + DataType.DOUBLE, DataType.NUMERIC -> value + else -> value + } + } + + if (description != null) { + val descCol = table.getColumn("DESCRIPTION") + if (descCol != null) { + updateMap[descCol.name] = description + } + } + + cursor.updateCurrentRowFromMap(updateMap) + found = true + break + } + } + + if (!found) { + return@withLock DctResult.Failure(DctError.SignalError.ConstantOutOfRange(name, value, 0.0, 0.0)) + } + + db.flush() + // 인메모리 도메인 모델 동기화 + cachedDomainDb = MdbSchemaParser.parseDatabase(db, currentDisplayName) + syncCurrentDbToLocalCache() + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "상수 업데이트 오류"), e) + } + } + } + + override suspend fun updateCompensationMap(name: String, points: List): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + val db = currentDb ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("열린 MDB 세션이 없습니다.")) + val table = db.getTable(name) ?: return@withLock DctResult.Failure(DctError.MdbError.TableNotFound(name)) + + val idColName = when { + table.getColumn("IDX") != null -> "IDX" + table.getColumn("ID") != null -> "ID" + table.getColumn("INDEX") != null -> "INDEX" + else -> table.columns.firstOrNull()?.name ?: "IDX" + } + + val bkpCol = table.getColumn("BKP") ?: table.getColumn("ENG_RPM") + val dataCol = table.getColumn("DATA") ?: table.getColumn("RAW_DATA") + val phyCol = table.getColumn("PHY") ?: table.getColumn("PHY_VALUE") ?: table.getColumn("CORRECTION_FACTOR") + + val cursor = CursorBuilder.createCursor(table) + while (cursor.moveToNextRow()) { + cursor.deleteCurrentRow() + } + + val idCol = table.getColumn(idColName) + for (pointData in points) { + val rowMap = mutableMapOf() + if (idCol != null) { + rowMap[idCol.name] = when (idCol.type) { + DataType.INT -> pointData.idx.toShort() + DataType.LONG -> pointData.idx.toLong() + DataType.DOUBLE -> pointData.idx.toDouble() + else -> pointData.idx + } + } + if (bkpCol != null) { + rowMap[bkpCol.name] = when (bkpCol.type) { + DataType.INT -> pointData.bkp.toShort() + DataType.LONG -> pointData.bkp.toLong() + DataType.DOUBLE -> pointData.bkp.toDouble() + else -> pointData.bkp + } + } + if (dataCol != null) { + rowMap[dataCol.name] = when (dataCol.type) { + DataType.INT -> pointData.data.toShort() + DataType.LONG -> pointData.data.toLong() + DataType.DOUBLE -> pointData.data.toDouble() + else -> pointData.data + } + } + if (phyCol != null) { + rowMap[phyCol.name] = when (phyCol.type) { + DataType.TEXT, DataType.MEMO -> String.format(Locale.US, "%.4f", pointData.phy) + DataType.FLOAT -> pointData.phy.toFloat() + DataType.DOUBLE, DataType.NUMERIC -> pointData.phy + else -> pointData.phy + } + } + table.addRowFromMap(rowMap) + } + + db.flush() + cachedDomainDb = MdbSchemaParser.parseDatabase(db, currentDisplayName) + syncCurrentDbToLocalCache() + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "1D 보정 맵 업데이트 오류"), e) + } + } + } + + override suspend fun updateCompensationMetadata( + isTypeA: Boolean, + yName: String, + min: Double, + max: Double, + cvsA: Double, + cvsB: Double, + description: String? + ): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + val db = currentDb ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("열린 MDB 세션이 없습니다.")) + val tableName = if (isTypeA) "Tb1DmPxDyLst" else "Tb1DmPyDxLst" + val table = db.getTable(tableName) ?: return@withLock DctResult.Failure(DctError.MdbError.TableNotFound(tableName)) + + val cursor = CursorBuilder.createCursor(table) + var found = false + + while (cursor.moveToNextRow()) { + val row = cursor.currentRow + val currentYName = row["YNAME"]?.toString() ?: continue + if (currentYName.equals(yName, ignoreCase = true)) { + val updateMap = mutableMapOf() + + table.getColumn("YMIN")?.let { col -> + updateMap[col.name] = if (col.type == DataType.DOUBLE) min else min.toLong() + } + table.getColumn("YMAX")?.let { col -> + updateMap[col.name] = if (col.type == DataType.DOUBLE) max else max.toLong() + } + table.getColumn("CVS_A")?.let { col -> + updateMap[col.name] = cvsA + } + table.getColumn("CVS_B")?.let { col -> + updateMap[col.name] = cvsB + } + if (description != null) { + table.getColumn("DESCRIPTION")?.let { col -> + updateMap[col.name] = description + } + } + + cursor.updateCurrentRowFromMap(updateMap) + found = true + break + } + } + + if (!found) { + return@withLock DctResult.Failure(DctError.MdbError.TableNotFound("보정 맵 메타데이터 '$yName'")) + } + + db.flush() + cachedDomainDb = MdbSchemaParser.parseDatabase(db, currentDisplayName) + syncCurrentDbToLocalCache() + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "보정 맵 메타데이터 수정 오류"), e) + } + } + } + + override suspend fun updateSignalDescription( + context: Context?, + signalName: String, + description: String + ): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + val db = currentDb ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("열린 MDB 세션이 없습니다.")) + var found = false + + // 1. VarLst 테이블 탐색 및 직접 레코드 수정 (PC C++ LibDB.cpp 100% 호환) + val varLstTable = db.getTable("VarLst") ?: db.getTable("varlst") ?: db.getTable("VARLST") + if (varLstTable != null) { + val descCol = varLstTable.getColumn("DESCRIPTION") + ?: varLstTable.getColumn("Description") + ?: varLstTable.getColumn("DESCRIP") + ?: varLstTable.getColumn("Descrip") + ?: varLstTable.getColumn("DESC") + + val cursor = CursorBuilder.createCursor(varLstTable) + while (cursor.moveToNextRow()) { + val row = cursor.currentRow + val nameVal = row["NAME"]?.toString() ?: row["Name"]?.toString() ?: "" + if (nameVal.equals(signalName, ignoreCase = true)) { + if (descCol != null) { + cursor.updateCurrentRowFromMap(mapOf(descCol.name to description)) + found = true + } + break + } + } + } + + // 2. 만약 VarLst에 없으면 GroupXXX_Sig 또는 모든 테이블에서 탐색 + if (!found) { + for (tName in db.tableNames) { + if (tName.startsWith("MSys") || tName.startsWith("~")) continue + val table = db.getTable(tName) ?: continue + val nameCol = table.getColumn("NAME") ?: table.getColumn("Name") ?: continue + val descCol = table.getColumn("DESCRIPTION") ?: table.getColumn("Description") ?: table.getColumn("DESCRIP") + if (descCol == null) continue + + val cursor = CursorBuilder.createCursor(table) + while (cursor.moveToNextRow()) { + val row = cursor.currentRow + val nameVal = row[nameCol.name]?.toString() ?: "" + if (nameVal.equals(signalName, ignoreCase = true)) { + cursor.updateCurrentRowFromMap(mapOf(descCol.name to description)) + found = true + break + } + } + if (found) break + } + } + + db.flush() + cachedDomainDb = MdbSchemaParser.parseDatabase(db, currentDisplayName) + + // 3. 만약 원본 SAF Uri나 로컬 파일이 있다면 즉시 덮어쓰기 플러시 (Auto-Sync) + syncCurrentDbToLocalCache(context) + + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "MDB 신호 Description 업데이트 오류"), e) + } + } + } + + private fun syncCurrentDbToLocalCache(context: Context? = null) { + try { + val file = currentDbFile ?: return + val baseDir = context?.filesDir ?: file.parentFile + if (baseDir != null) { + val importedDir = File(baseDir, "imported_files").apply { if (!exists()) mkdirs() } + val localImported = File(importedDir, currentDisplayName) + file.copyTo(localImported, overwrite = true) + } + } catch (_: Exception) {} + + if (context != null && currentOriginalUri != null) { + try { + context.contentResolver.openOutputStream(currentOriginalUri!!, "wt")?.use { os -> + currentDbFile?.inputStream()?.use { input -> + input.copyTo(os) + } + } + } catch (_: Exception) {} + } + } + + override suspend fun createStandardSampleDatabase(outputStream: OutputStream): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + VirtualMdbGenerator.createStandardDatabase(outputStream) + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "표준 샘플 생성 실패"), e) + } + } + } + + override suspend fun createStandardSampleDatabase(targetFile: File): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + closeInternal() + val db = VirtualMdbGenerator.createStandardDatabase(targetFile) + + currentDb = db + currentDbFile = targetFile + currentDisplayName = targetFile.name + + val domainDb = MdbSchemaParser.parseDatabase(db, targetFile.name) + cachedDomainDb = domainDb + cachedDbInfo = buildDbInfo(targetFile, db, targetFile.name) + cachedTableNames = db.tableNames.filter { !it.startsWith("MSys") && !it.startsWith("~") }.sorted() + + DctResult.Success(domainDb) + } catch (e: Exception) { + DctResult.Failure(DctError.MdbError.CorruptedDatabase(e.message ?: "표준 샘플 생성 실패"), e) + } + } + } + + override fun getCurrentDatabase(): MdbDatabase? = cachedDomainDb + + // ========================================================================= + // 2. 범용 테이블 데이터 탐색 및 CRUD 호환 API + // ========================================================================= + + override suspend fun openDatabase(context: Context, uri: Uri): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + closeInternal() + currentOriginalUri = uri + + try { + val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + } catch (_: Exception) {} + + // 1. 파일명 추출 (SAF Display Name 또는 Uri 파싱) + var displayName = "imported_database.mdb" + try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1 && cursor.moveToFirst()) { + displayName = cursor.getString(nameIndex) ?: "imported_database.mdb" + } + } + } catch (_: Exception) { + uri.lastPathSegment?.let { seg -> + val name = seg.substringAfterLast("/") + if (name.isNotBlank()) displayName = name + } + } + currentDisplayName = displayName + + // 2. Content URI 스트림을 캐시 임시 파일로 복사 + val cacheFile = File(context.cacheDir, displayName) + if (cacheFile.exists()) { + cacheFile.delete() + } + + var copySuccess = false + try { + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(cacheFile).use { output -> + input.copyTo(output) + } + copySuccess = true + } + } catch (se: SecurityException) { + // SAF 권한 거부 시, 내부 imported_files 폴더에 저장된 로컬 백업 탐색 + val localBackup = File(context.filesDir, "imported_files/$displayName") + if (localBackup.exists() && localBackup.length() > 0) { + localBackup.copyTo(cacheFile, overwrite = true) + copySuccess = true + } else { + return@withLock DctResult.Failure( + DctError.MdbError.SafAccessDenied("외부 MDB 접근 권한이 만료되었습니다. MDB 파일을 다시 선택해 주세요.") + ) + } + } catch (e: Exception) { + val localBackup = File(context.filesDir, "imported_files/$displayName") + if (localBackup.exists() && localBackup.length() > 0) { + localBackup.copyTo(cacheFile, overwrite = true) + copySuccess = true + } else { + return@withLock DctResult.Failure( + DctError.MdbError.FileNotFound("MDB 파일을 읽을 수 없습니다: ${e.message}") + ) + } + } + + if (!copySuccess || !cacheFile.exists() || cacheFile.length() == 0L) { + val localBackup = File(context.filesDir, "imported_files/$displayName") + if (localBackup.exists() && localBackup.length() > 0) { + localBackup.copyTo(cacheFile, overwrite = true) + } else { + return@withLock DctResult.Failure(DctError.MdbError.SafAccessDenied("MDB 파일 데이터를 읽을 수 없습니다.")) + } + } + + // 다음 세션 영속성을 위해 imported_files에 보관 + try { + val importedDir = File(context.filesDir, "imported_files").apply { if (!exists()) mkdirs() } + cacheFile.copyTo(File(importedDir, displayName), overwrite = true) + } catch (_: Exception) {} + + val db = DatabaseBuilder.open(cacheFile) + + currentDb = db + currentDbFile = cacheFile + + val info = buildDbInfo(cacheFile, db, displayName) + cachedDbInfo = info + cachedDomainDb = MdbSchemaParser.parseDatabase(db, displayName) + cachedTableNames = db.tableNames.filter { !it.startsWith("MSys") && !it.startsWith("~") }.sorted() + DctResult.Success(info) + } catch (e: Exception) { + DctResult.Failure(DctError.MdbError.CorruptedDatabase(e.message ?: "열기 실패"), e) + } + } + } + + override suspend fun openDatabase(file: File): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + closeInternal() + if (!file.exists()) { + return@withLock DctResult.Failure(DctError.MdbError.FileNotFound(file.absolutePath)) + } + + val db = DatabaseBuilder.open(file) + + currentDb = db + currentDbFile = file + currentDisplayName = file.name + + val info = buildDbInfo(file, db, file.name) + cachedDbInfo = info + cachedDomainDb = MdbSchemaParser.parseDatabase(db, file.name) + cachedTableNames = db.tableNames.filter { !it.startsWith("MSys") && !it.startsWith("~") }.sorted() + DctResult.Success(info) + } catch (e: Exception) { + DctResult.Failure(DctError.MdbError.CorruptedDatabase(e.message ?: "파일 로드 실패"), e) + } + } + } + + override suspend fun createSampleDatabase(targetFile: File): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + closeInternal() + val db = VirtualMdbGenerator.createStandardDatabase(targetFile) + + currentDb = db + currentDbFile = targetFile + currentDisplayName = targetFile.name + + val info = buildDbInfo(targetFile, db, targetFile.name) + cachedDbInfo = info + cachedDomainDb = MdbSchemaParser.parseDatabase(db, targetFile.name) + cachedTableNames = db.tableNames.filter { !it.startsWith("MSys") && !it.startsWith("~") }.sorted() + DctResult.Success(info) + } catch (e: Exception) { + DctResult.Failure(DctError.MdbError.CorruptedDatabase(e.message ?: "생성 실패"), e) + } + } + } + + override fun getTableNames(): List { + val cached = cachedTableNames + if (cached.isNotEmpty()) return cached + return synchronized(this) { + val db = currentDb ?: return emptyList() + try { + db.tableNames + .filter { !it.startsWith("MSys") && !it.startsWith("~") } + .sorted() + } catch (_: Exception) { + emptyList() + } + } + } + + override suspend fun getTableData(tableName: String): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + val db = currentDb ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("DB 미열림")) + val table = db.getTable(tableName) ?: return@withLock DctResult.Failure(DctError.MdbError.TableNotFound(tableName)) + + val columns = table.columns.map { col -> + MdbColumn( + name = col.name, + type = col.type, + typeName = col.type.name, + length = col.length.toInt(), + isAutoNumber = col.isAutoNumber + ) + } + + val rows = mutableListOf() + var rowIndex = 0 + for (row in table) { + val valuesMap = mutableMapOf() + for (col in columns) { + valuesMap[col.name] = row[col.name] + } + rows.add(MdbRow(rowIndex++, valuesMap)) + } + + DctResult.Success(MdbTable(name = tableName, columns = columns, rows = rows)) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "테이블 조회 오류"), e) + } + } + } + + override suspend fun addRow(tableName: String, values: Map): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + val db = currentDb ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("DB 미열림")) + val table = db.getTable(tableName) ?: return@withLock DctResult.Failure(DctError.MdbError.TableNotFound(tableName)) + + val insertMap = mutableMapOf() + for (col in table.columns) { + if (col.isAutoNumber) { + insertMap[col.name] = Column.AUTO_NUMBER + } else if (values.containsKey(col.name)) { + insertMap[col.name] = convertValueToType(values[col.name], col.type) + } + } + + table.addRowFromMap(insertMap) + db.flush() + + currentDbFile?.let { cachedDbInfo = buildDbInfo(it, db, currentDisplayName) } + cachedDomainDb = MdbSchemaParser.parseDatabase(db, currentDisplayName) + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "행 추가 오류"), e) + } + } + } + + override suspend fun updateRow(tableName: String, rowIndex: Int, values: Map): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + val db = currentDb ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("DB 미열림")) + val table = db.getTable(tableName) ?: return@withLock DctResult.Failure(DctError.MdbError.TableNotFound(tableName)) + + val cursor = CursorBuilder.createCursor(table) + var curIdx = 0 + var found = false + + while (cursor.moveToNextRow()) { + if (curIdx == rowIndex) { + val updateMap = mutableMapOf() + for (col in table.columns) { + if (!col.isAutoNumber && values.containsKey(col.name)) { + updateMap[col.name] = convertValueToType(values[col.name], col.type) + } + } + cursor.updateCurrentRowFromMap(updateMap) + found = true + break + } + curIdx++ + } + + if (!found) { + return@withLock DctResult.Failure(DctError.Unknown("수정할 행(Index: $rowIndex)을 찾을 수 없습니다.")) + } + + db.flush() + cachedDomainDb = MdbSchemaParser.parseDatabase(db, currentDisplayName) + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "행 수정 오류"), e) + } + } + } + + override suspend fun deleteRow(tableName: String, rowIndex: Int): DctResult = withContext(dispatchers.io) { + mutex.withLock { + try { + val db = currentDb ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("DB 미열림")) + val table = db.getTable(tableName) ?: return@withLock DctResult.Failure(DctError.MdbError.TableNotFound(tableName)) + + val cursor = CursorBuilder.createCursor(table) + var curIdx = 0 + var found = false + + while (cursor.moveToNextRow()) { + if (curIdx == rowIndex) { + cursor.deleteCurrentRow() + found = true + break + } + curIdx++ + } + + if (!found) { + return@withLock DctResult.Failure(DctError.Unknown("삭제할 행(Index: $rowIndex)을 찾을 수 없습니다.")) + } + + db.flush() + currentDbFile?.let { cachedDbInfo = buildDbInfo(it, db, currentDisplayName) } + cachedDomainDb = MdbSchemaParser.parseDatabase(db, currentDisplayName) + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "행 삭제 오류"), e) + } + } + } + + override suspend fun saveToUri(context: Context, targetUri: Uri): DctResult = withContext(dispatchers.io) { + mutex.withLock { + val file = currentDbFile ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("저장할 파일 없음")) + val backupFile = File(file.parentFile, "${file.name}.bak") + + try { + currentDb?.flush() + currentDb?.close() + + // 1. 임시 안전 백업 생성 (Atomic Write Guard) + file.copyTo(backupFile, overwrite = true) + + // 2. SAF 스트림으로 복사 + var safSaved = false + try { + context.contentResolver.openOutputStream(targetUri, "rwt")?.use { output -> + file.inputStream().use { input -> + input.copyTo(output, bufferSize = 8192) + } + output.flush() + safSaved = true + } + } catch (_: Exception) { + try { + context.contentResolver.openOutputStream(targetUri, "wt")?.use { output -> + file.inputStream().use { input -> + input.copyTo(output, bufferSize = 8192) + } + output.flush() + safSaved = true + } + } catch (_: Exception) {} + } + + // 3. 앱 내부 영구 캐시(imported_files)에도 동시 동기화 (재오픈 시 일치 보장) + try { + val importedDir = File(context.filesDir, "imported_files").apply { if (!exists()) mkdirs() } + val localImported = File(importedDir, currentDisplayName) + file.copyTo(localImported, overwrite = true) + } catch (_: Exception) {} + + backupFile.delete() + + // 4. 활성 세션 DB 재오픈 + val reOpenedDb = DatabaseBuilder.open(file) + currentDb = reOpenedDb + + DctResult.Success(Unit) + } catch (e: Exception) { + // 실패 시 백업 복원 + if (backupFile.exists()) { + backupFile.copyTo(file, overwrite = true) + backupFile.delete() + } + try { + val reOpenedDb = DatabaseBuilder.open(file) + currentDb = reOpenedDb + } catch (_: Exception) {} + + DctResult.Failure(DctError.Unknown(e.message ?: "SAF 원자적 저장 오류"), e) + } + } + } + + override suspend fun saveToOriginal(context: Context): DctResult = withContext(dispatchers.io) { + val srcUri = currentOriginalUri + if (srcUri != null) { + saveToUri(context, srcUri) + } else { + // 외부 Uri가 없는 로컬 파일 세션인 경우 로컬 캐시 동기화 수행 + mutex.withLock { + try { + val file = currentDbFile ?: return@withLock DctResult.Failure(DctError.MdbError.FileNotFound("저장할 파일 없음")) + currentDb?.flush() + try { + val importedDir = File(context.filesDir, "imported_files").apply { if (!exists()) mkdirs() } + val localImported = File(importedDir, currentDisplayName) + file.copyTo(localImported, overwrite = true) + } catch (_: Exception) {} + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "로컬 MDB 동기화 저장 실패"), e) + } + } + } + } + + override fun getCurrentDbInfo(): MdbDatabaseInfo? = cachedDbInfo + + override fun close() { + synchronized(this) { + closeInternal() + } + } + + private fun closeInternal() { + try { + currentDb?.flush() + currentDb?.close() + } catch (_: Exception) {} + currentDb = null + currentDbFile = null + cachedDbInfo = null + cachedDomainDb = null + cachedTableNames = emptyList() + } + + private fun buildDbInfo(file: File, db: Database, displayName: String): MdbDatabaseInfo { + val sizeKb = file.length() / 1024.0 + val sizeFormatted = if (sizeKb >= 1024) { + String.format(Locale.getDefault(), "%.2f MB", sizeKb / 1024.0) + } else { + String.format(Locale.getDefault(), "%.2f KB", sizeKb) + } + + val tableSummaries = mutableListOf() + val tableNames = db.tableNames.filter { !it.startsWith("MSys") && !it.startsWith("~") }.sorted() + + for (name in tableNames) { + try { + val table = db.getTable(name) + if (table != null) { + tableSummaries.add(MdbTableSummary(name, table.rowCount, table.columnCount)) + } + } catch (_: Exception) { + tableSummaries.add(MdbTableSummary(name, 0, 0)) + } + } + + return MdbDatabaseInfo( + fileName = displayName, + filePath = file.absolutePath, + fileSizeFormatted = sizeFormatted, + fileFormat = db.fileFormat.name, + tableCount = tableSummaries.size, + tables = tableSummaries + ) + } + + private fun convertValueToType(value: Any?, type: DataType): Any? { + if (value == null) return null + val str = value.toString().trim() + if (str.isEmpty()) return null + + return try { + when (type) { + DataType.TEXT, DataType.MEMO -> str + DataType.BOOLEAN -> str.equals("true", true) || str == "1" || str.equals("Y", true) + DataType.BYTE -> str.toDouble().toInt().toByte() + DataType.INT -> str.toDouble().toInt().toShort() + DataType.LONG -> str.toDouble().toInt() + DataType.FLOAT -> str.toFloat() + DataType.DOUBLE, DataType.NUMERIC, DataType.MONEY -> str.toDouble() + DataType.SHORT_DATE_TIME -> parseDate(str) + else -> str + } + } catch (_: Exception) { + str + } + } + + private fun parseDate(dateStr: String): Date { + val formats = arrayOf( + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd", + "yyyy/MM/dd HH:mm:ss", + "yyyy/MM/dd" + ) + for (pattern in formats) { + try { + return SimpleDateFormat(pattern, Locale.getDefault()).parse(dateStr) ?: Date() + } catch (_: Exception) {} + } + return Date() + } +} + diff --git a/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt b/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt new file mode 100644 index 0000000..fc4a062 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt @@ -0,0 +1,394 @@ +package com.dct.app.data.repository + +import com.dct.app.core.common.DctResult +import com.dct.app.core.common.PacketDistributor +import com.dct.app.core.error.DctError +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.protocol.DctCommand +import com.dct.app.domain.model.protocol.DctMeasurementConfiguration +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.PacketRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.util.concurrent.ConcurrentHashMap + +/** 실제 전송 성공과 장비 응답 성공을 분리하여 관리하는 DCT 통신 Repository. */ +class PacketRepositoryImpl( + val wifiSocketManager: WifiSocketManager? = null, + private val packetDistributor: PacketDistributor = PacketDistributor(), + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val responseTimeoutMs: Long = DEFAULT_RESPONSE_TIMEOUT_MS +) : PacketRepository { + private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) + private val requestMutex = Mutex() + private val pendingResponses = ConcurrentHashMap>() + private var xcpIsMsbFirst = false + + private val _isConnected = MutableStateFlow(false) + override val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionState = MutableStateFlow(DeviceConnectionState.DISCONNECTED) + override val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _isStreaming = MutableStateFlow(false) + override val isStreaming: StateFlow = _isStreaming.asStateFlow() + + override val packetStream: SharedFlow = packetDistributor.packetStream + + init { + wifiSocketManager?.let { manager -> + scope.launch(start = CoroutineStart.UNDISPATCHED) { collectTransportPackets(manager.packetStream) } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + manager.isConnected.drop(1).collect { transportConnected -> + if (!transportConnected && _connectionState.value !in setOf( + DeviceConnectionState.DISCONNECTED, + DeviceConnectionState.CONNECTING + ) + ) { + failPendingRequests("Wi-Fi TCP 연결이 종료되었습니다.") + _isStreaming.value = false + transition(DeviceConnectionState.ERROR) + } + } + } + } + } + + override suspend fun connect(target: String): DctResult = withContext(ioDispatcher) { + transition(DeviceConnectionState.CONNECTING) + _isStreaming.value = false + if (!hasLiveTransport()) { + transition(DeviceConnectionState.ERROR) + return@withContext failure("Wi-Fi TCP 전송 계층이 연결되지 않았습니다.") + } + + transition(DeviceConnectionState.HANDSHAKING) + val handshake = listOf( + DctProtocolRequests.enable(), + DctProtocolRequests.version(), + DctProtocolRequests.hardwareId(), + DctProtocolRequests.syncTime() + ) + for (request in handshake) { + val result = sendCommand(request) + if (result !is DctResult.Success || result.data == null) { + transition(DeviceConnectionState.ERROR) + return@withContext if (result is DctResult.Failure) result else failure( + "Handshake response missing for ${DctCommand.toHexString(request.command)}" + ) + } + try { + com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, result.data) + } catch (e: IllegalArgumentException) { + transition(DeviceConnectionState.ERROR) + return@withContext failure("연결 초기화 ${DctCommand.toHexString(request.command)}: ${e.message}") + } + } + + transition(DeviceConnectionState.READY) + DctResult.Success(Unit) + } + + override suspend fun disconnect(): DctResult = withContext(ioDispatcher) { + try { + if (_isStreaming.value) stopStreaming() + if (hasLiveTransport()) sendCommand(DctProtocolRequests.disable()) + } finally { + transition(DeviceConnectionState.DISCONNECTED) + failPendingRequests("연결이 해제되었습니다.") + wifiSocketManager?.disconnectSuspend() + _isStreaming.value = false + } + DctResult.Success(Unit) + } + + override suspend fun sendCommand(command: DctPacket): DctResult = withContext(ioDispatcher) { + val expectedResponse = expectedResponse(command.command) + + if (!hasLiveTransport()) return@withContext failure("전송 가능한 실장비 연결이 없습니다.") + + if (expectedResponse == null) { + return@withContext if (sendLive(command)) DctResult.Success(null) + else failure("명령 송신 실패: ${DctCommand.toHexString(command.command)}") + } + + requestMutex.withLock { + val deferred = CompletableDeferred() + pendingResponses[expectedResponse] = deferred + try { + if (!sendLive(command)) { + return@withLock failure("명령 송신 실패: ${DctCommand.toHexString(command.command)}") + } + DctResult.Success(withTimeout(responseTimeoutMs) { deferred.await() }) + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + // The protocol has no transaction ID. A late ACK must not satisfy the next write. + transition(DeviceConnectionState.ERROR) + _isStreaming.value = false + wifiSocketManager?.disconnectSuspend() + failure( + "응답 시간 초과: ${DctCommand.toHexString(command.command)} -> ${DctCommand.toHexString(expectedResponse)}. 장비 반영 여부 확인 후 다시 연결하세요." + ) + } catch (e: CancellationException) { + // Once sent, cancellation also leaves an ambiguous transaction on this byte stream. + transition(DeviceConnectionState.ERROR) + _isStreaming.value = false + withContext(kotlinx.coroutines.NonCancellable) { wifiSocketManager?.disconnectSuspend() } + throw e + } catch (e: Exception) { + DctResult.Failure(DctError.Unknown(e.message ?: "Command execution error"), e) + } finally { + pendingResponses.remove(expectedResponse, deferred) + } + } + } + + override suspend fun configureMeasurement(configuration: DctMeasurementConfiguration): DctResult = + withContext(ioDispatcher) { + if (_connectionState.value != DeviceConnectionState.READY) { + return@withContext failure("장비 설정은 READY 상태에서만 적용할 수 있습니다.") + } + + val requests = try { + fun packet(step: String, build: () -> DctPacket): DctPacket = try { + build() + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("$step: ${e.message}", e) + } + buildList { + if (configuration.vehicleCode.isNotBlank()) { + add(packet("차종 설정") { + DctProtocolRequests.carType(configuration.vehicleCode, configuration.maker) + }) + } + add(packet("CAN 모드") { + DctProtocolRequests.canMode(configuration.isHighSpeedCan, configuration.isOldModel) + }) + add(packet("MDB 정의") { DctProtocolRequests.mdbInfo(configuration.mdbSignals) }) + add(packet("MDB 선택 ID") { + DctProtocolRequests.mdbSelectedIds(configuration.selectedMdbSignals) + }) + add(packet("CAN DBC") { DctProtocolRequests.canDbcInfo(configuration.canMessages) }) + if (configuration.sensorModules.isNotEmpty()) { + add(packet("센서 모듈") { + DctProtocolRequests.sensorModuleInfo(configuration.sensorModules) + }) + } + } + } catch (e: IllegalArgumentException) { + return@withContext failure("장비 설정 데이터가 프로토콜 범위를 벗어났습니다: ${e.message}") + } + + for (request in requests) { + val result = sendCommand(request) + if (result !is DctResult.Success || result.data == null) { + return@withContext if (result is DctResult.Failure) result else failure( + "장비 설정 응답 누락: ${DctCommand.toHexString(request.command)}" + ) + } + try { + com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, result.data) + } catch (e: IllegalArgumentException) { + return@withContext failure("장비 설정 ${DctCommand.toHexString(request.command)}: ${e.message}") + } + } + DctResult.Success(Unit) + } + + override suspend fun startStreaming(frequencyHz: Int): DctResult = withContext(ioDispatcher) { + if (_connectionState.value != DeviceConnectionState.READY) { + return@withContext failure("계측 시작은 READY 상태에서만 가능합니다.") + } + val result = sendCommand(DctProtocolRequests.startAll()) + val response = (result as? DctResult.Success)?.data + if (response?.payload?.firstOrNull() == STATUS_SUCCESS) { + _isStreaming.value = true + transition(DeviceConnectionState.MEASURING) + DctResult.Success(Unit) + } else if (result is DctResult.Failure) result + else failure("장비가 계측 시작을 거절했습니다.") + } + + override suspend fun stopStreaming(): DctResult = withContext(ioDispatcher) { + if (_connectionState.value != DeviceConnectionState.MEASURING) { + return@withContext failure("현재 계측 중이 아닙니다.") + } + val result = sendCommand(DctProtocolRequests.stop()) + val response = (result as? DctResult.Success)?.data + if (response != null && (response.payload.isEmpty() || response.payload[0] == STATUS_SUCCESS)) { + _isStreaming.value = false + transition(DeviceConnectionState.READY) + DctResult.Success(Unit) + } else if (result is DctResult.Failure) result + else failure("장비가 계측 정지를 확인하지 않았습니다.") + } + + override suspend fun readConstant(address: Long, size: Int): DctResult = withContext(ioDispatcher) { + val result = sendCommand(DctProtocolRequests.constantRead(address, size)) + val response = (result as? DctResult.Success)?.data + if (response == null) return@withContext propagateOrFail(result, "상수 읽기 응답이 없습니다.") + val payload = response.payload + if (payload.size < 2 || payload[0] != STATUS_SUCCESS) return@withContext failure("상수 읽기가 거절되었습니다.") + val returnedSize = payload[1].toInt() and 0xFF + if (returnedSize != size || payload.size < 2 + returnedSize) { + return@withContext DctResult.Failure(DctError.SignalError.PayloadIndexOutOfBounds(2 + size, payload.size)) + } + DctResult.Success(payload.copyOfRange(2, 2 + returnedSize)) + } + + override suspend fun writeConstant(address: Long, data: ByteArray): DctResult = withContext(ioDispatcher) { + statusCommand(DctProtocolRequests.constantWrite(address, data), "상수 쓰기") + } + + override suspend fun sendXcpUse(mode: Int): DctResult = withContext(ioDispatcher) { + statusCommand(DctProtocolRequests.xcpUse(mode), "XCP USE 설정") + } + + override suspend fun sendXcpIfDatas(ifData: com.dct.app.data.parser.A2lParser.XcpIfData): DctResult = + withContext(ioDispatcher) { + val result = statusCommand(DctProtocolRequests.xcpIfData(ifData), "XCP IFDATA 설정") + if (result is DctResult.Success) xcpIsMsbFirst = ifData.isMsbFirst + result + } + + override suspend fun sendXcpMeasurements(measurements: List): DctResult = + withContext(ioDispatcher) { + statusCommand( + DctProtocolRequests.xcpMeasurements(measurements, xcpIsMsbFirst), + "XCP Measurement 설정" + ) + } + + override suspend fun writeXcpCharacteristic( + address: Long, + dataType: A2lDataType, + value: Double + ): DctResult = withContext(ioDispatcher) { + statusCommand( + DctProtocolRequests.xcpCharacteristicWrite(address, dataType, value, xcpIsMsbFirst), + "XCP Characteristic 쓰기" + ) + } + + override suspend fun readXcpCharacteristic( + address: Long, + dataType: A2lDataType + ): DctResult = withContext(ioDispatcher) { + val result = sendCommand(DctProtocolRequests.xcpCharacteristicRead(address, dataType, xcpIsMsbFirst)) + val response = (result as? DctResult.Success)?.data + if (response == null) return@withContext propagateOrFail(result, "XCP Characteristic 읽기 응답이 없습니다.") + if (response.payload.isEmpty() || response.payload[0] != STATUS_SUCCESS) { + return@withContext failure("XCP Characteristic 읽기가 거절되었습니다.") + } + if (response.payload.size < dataType.byteSize + 1) { + return@withContext DctResult.Failure( + DctError.SignalError.PayloadIndexOutOfBounds(dataType.byteSize + 1, response.payload.size) + ) + } + DctResult.Success(response.payload.copyOfRange(response.payload.size - dataType.byteSize, response.payload.size)) + } + + private suspend fun collectTransportPackets(stream: SharedFlow) { + while (true) { + try { + stream.collect { packet -> + pendingResponses.remove(packet.command)?.complete(packet) + when (packet.command) { + DctCommand.SEND_CAN_FRAME.code -> sendLive( + DctProtocolRequests.canStreamAck( + com.dct.app.data.protocol.DctStreamPayloadParser.parseCanStream(packet.payload) != null + ) + ) + DctCommand.SEND_SM_DATA.code -> sendLive( + DctProtocolRequests.smStreamAck( + com.dct.app.data.protocol.DctStreamPayloadParser.parseSensorStream(packet.payload) != null + ) + ) + DctCommand.SEND_XCP_MEASUREMENT_DATA.code -> sendLive( + DctProtocolRequests.xcpStreamAck( + com.dct.app.data.protocol.DctStreamPayloadParser.parseXcpStream(packet.payload) != null + ) + ) + } + packetDistributor.dispatchPacket(packet) + } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + // 예외 발생 시 잠시 대기 후 스트림 수집 루프 재개 + kotlinx.coroutines.delay(100) + } + } + } + + private suspend fun sendLive(packet: DctPacket): Boolean { + wifiSocketManager?.let { manager -> + if (manager.isConnected.value && manager.sendCommand(packet)) return true + } + return false + } + + private fun hasLiveTransport(): Boolean = + wifiSocketManager?.isConnected?.value == true + + private fun transition(state: DeviceConnectionState) { + _connectionState.value = state + _isConnected.value = state == DeviceConnectionState.READY || state == DeviceConnectionState.MEASURING + } + + private fun failPendingRequests(reason: String) { + val exception = IllegalStateException(reason) + pendingResponses.values.forEach { it.completeExceptionally(exception) } + pendingResponses.clear() + } + + private suspend fun statusCommand(request: DctPacket, name: String): DctResult { + val result = sendCommand(request) + val response = (result as? DctResult.Success)?.data + return if (response?.payload?.firstOrNull() == STATUS_SUCCESS) DctResult.Success(Unit) + else if (result is DctResult.Failure) result + else failure("$name 명령이 거절되었습니다.") + } + + private fun expectedResponse(command: Byte): Byte? = when (command.toInt() and 0xFF) { + in 0x00..0x09 -> ((command.toInt() and 0xFF) + 0x80).toByte() + 0x0F -> null // PC disable has no defined response; close immediately after sending. + in 0x10..0x18 -> ((command.toInt() and 0xFF) + 0x80).toByte() + in 0x20..0x22 -> ((command.toInt() and 0xFF) + 0x80).toByte() + in 0x25..0x28 -> ((command.toInt() and 0xFF) + 0x80).toByte() + 0x31 -> 0xB1.toByte() + 0x34 -> 0xB4.toByte() + in 0x40..0x42 -> ((command.toInt() and 0xFF) + 0x80).toByte() + in 0x51..0x52 -> ((command.toInt() and 0xFF) + 0x80).toByte() + else -> null // 0x23, 0x24, 0x50 are host ACKs and have no response. + } + + private fun failure(reason: String): DctResult.Failure = + DctResult.Failure(DctError.ProtocolError.ConnectionFailed(reason)) + + private fun propagateOrFail(result: DctResult<*>, reason: String): DctResult = + if (result is DctResult.Failure) result else failure(reason) + + companion object { + private const val DEFAULT_RESPONSE_TIMEOUT_MS = 3_000L + private const val STATUS_SUCCESS: Byte = 0x01 + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/ringbuffer/TimeSeriesRingBuffer.kt b/Android/app/src/main/java/com/dct/app/data/ringbuffer/TimeSeriesRingBuffer.kt new file mode 100644 index 0000000..3ae1285 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/ringbuffer/TimeSeriesRingBuffer.kt @@ -0,0 +1,234 @@ +package com.dct.app.data.ringbuffer + +/** + * 고성능 Zero-GC 시계열 원형 링 버퍼 (Time Series Primitive Ring Buffer) + * + * - 객체 생성으로 인한 GC 오버헤드를 원천 차단하기 위해 원시 DoubleArray(타임스탬프, 값)를 사전 할당(Pre-allocation)합니다. + * - O(1) 원소 삽입 및 O(1) 논리 인덱스 임의 접근을 지원합니다. + * - 버퍼 용량(capacity) 초과 시 가장 오래된 데이터를 덮어쓰며 head를 전진(FIFO)시킵니다. + * - 멀티스레드 환경에서 안전하도록 주요 쓰기/초기화 연산에 동기화(@Synchronized)를 적용합니다. + */ +class TimeSeriesRingBuffer(val capacity: Int = 100_000) { + + init { + require(capacity > 0) { "버퍼 용량(capacity)은 0보다 커야 합니다. (입력값: $capacity)" } + } + + // 타임스탬프 (초 단위 또는 밀리초 단위 부동소수점) + private val timestamps = DoubleArray(capacity) + // 물리 계측값 + private val values = DoubleArray(capacity) + + // 가장 오래된 데이터의 물리적 인덱스 + @Volatile + private var head = 0 + + // 현재 버퍼에 유효하게 저장된 데이터 포인트 개수 + @Volatile + var size = 0 + private set + + /** + * 신규 시계열 샘플 추가 (O(1)) + * - PC C++ MultiXGraphWnd RemovePointsFromEnd(1) 호환: + * 직전 샘플과 타임스탬프가 동일한 경우 수직 계단선 왜곡 방지를 위해 마지막 값을 덮어씁니다. + * - 타임스탬프 단조 증가(Monotonic) 보장: 역전 방지 클램핑. + * + * @param timestampSec 타임스탬프 (초 단위 등 단조 증가 값) + * @param value 물리 계측값 + */ + @Synchronized + fun addSample(timestampSec: Double, value: Double) { + if (!timestampSec.isFinite() || !value.isFinite()) return + + if (size > 0) { + val lastIdx = (head + size - 1) % capacity + val prevTime = timestamps[lastIdx] + + // PC C++ 호환: 직전 점과 타임스탬프가 동일(차이 < 1ms)하면 수직선 왜곡 방지를 위해 덮어씀 + if (kotlin.math.abs(timestampSec - prevTime) < 0.001) { + values[lastIdx] = value + return + } + + // 타임스탬프 역전 방지 클램핑 (네트워크 지터 방어) + val monotonicTime = if (timestampSec < prevTime) prevTime + 0.001 else timestampSec + + if (size < capacity) { + val insertIdx = (head + size) % capacity + timestamps[insertIdx] = monotonicTime + values[insertIdx] = value + size++ + } else { + timestamps[head] = monotonicTime + values[head] = value + head = (head + 1) % capacity + } + } else { + timestamps[0] = timestampSec + values[0] = value + head = 0 + size = 1 + } + } + + /** + * 편의 오버로딩: Long 타임스탬프(ms)와 Double 값 추가 + */ + fun addSample(timestampMs: Long, value: Double) { + addSample(timestampMs.toDouble() / 1000.0, value) + } + + /** + * 0-based 논리 인덱스 [0, size-1]의 타임스탬프 조회 (O(1)) + * 0은 가장 오래된 샘플, size-1은 가장 최근 샘플 + */ + fun getTimestampAt(logicalIndex: Int): Double { + val currentSize = size + if (logicalIndex < 0 || logicalIndex >= currentSize) { + throw IndexOutOfBoundsException("논리 인덱스 범위를 벗어났습니다: index=$logicalIndex, size=$currentSize") + } + val physicalIdx = (head + logicalIndex) % capacity + return timestamps[physicalIdx] + } + + /** + * 0-based 논리 인덱스 [0, size-1]의 물리값 조회 (O(1)) + */ + fun getValueAt(logicalIndex: Int): Double { + val currentSize = size + if (logicalIndex < 0 || logicalIndex >= currentSize) { + throw IndexOutOfBoundsException("논리 인덱스 범위를 벗어났습니다: index=$logicalIndex, size=$currentSize") + } + val physicalIdx = (head + logicalIndex) % capacity + return values[physicalIdx] + } + + /** + * 가장 최근(최신) 타임스탬프 조회 (버퍼가 비어있으면 null) + */ + fun getLatestTimestamp(): Double? { + val currentSize = size + if (currentSize == 0) return null + return getTimestampAt(currentSize - 1) + } + + /** + * 가장 최근(최신) 물리값 조회 (버퍼가 비어있으면 null) + */ + fun getLatestValue(): Double? { + val currentSize = size + if (currentSize == 0) return null + return getValueAt(currentSize - 1) + } + + /** + * 가장 오래된 타임스탬프 조회 (버퍼가 비어있으면 null) + */ + fun getOldestTimestamp(): Double? { + val currentSize = size + if (currentSize == 0) return null + return getTimestampAt(0) + } + + /** + * 가장 오래된 물리값 조회 (버퍼가 비어있으면 null) + */ + fun getOldestValue(): Double? { + val currentSize = size + if (currentSize == 0) return null + return getValueAt(0) + } + + /** Copy only the original samples bracketing a cursor, atomically with buffer writes. + * Outside recorded data is deliberately empty: a future cursor must not follow latest. + */ + @Synchronized + fun captureAt(targetTimestamp: Double): List> { + if (!targetTimestamp.isFinite() || size == 0 || + targetTimestamp < getTimestampAt(0) || targetTimestamp > getTimestampAt(size - 1)) { + return emptyList() + } + var low = 0 + var high = size - 1 + while (low < high) { + val mid = (low + high) ushr 1 + if (getTimestampAt(mid) < targetTimestamp) low = mid + 1 else high = mid + } + val next = getTimestampAt(low) to getValueAt(low) + return if (next.first == targetTimestamp) listOf(next) + else listOf(getTimestampAt(low - 1) to getValueAt(low - 1), next) + } + + /** + * 임의의 타겟 타임스탬프에 대한 선형 보간(Linear Interpolation) 값 계산 + * + * @param targetTimestamp 보간할 타겟 타임스탬프 + * @return 보간된 물리값. 데이터가 없거나 타겟이 전체 범위 밖인 경우 클램핑 또는 null 반환 + */ + fun interpolateAt(targetTimestamp: Double): Double? { + val count = size + if (count == 0) return null + if (count == 1) return getValueAt(0) + + val firstT = getTimestampAt(0) + val lastT = getTimestampAt(count - 1) + + if (targetTimestamp <= firstT) return getValueAt(0) + if (targetTimestamp >= lastT) return getValueAt(count - 1) + + // 이진 탐색으로 targetTimestamp가 포함된 인접 구간 [mid, mid + 1] 탐색 + var low = 0 + var high = count - 1 + var segIdx = 0 + + while (low <= high) { + val mid = (low + high) ushr 1 + val tMid = getTimestampAt(mid) + if (tMid <= targetTimestamp) { + segIdx = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + + if (segIdx >= count - 1) { + return getValueAt(count - 1) + } + + val t0 = getTimestampAt(segIdx) + val t1 = getTimestampAt(segIdx + 1) + val v0 = getValueAt(segIdx) + val v1 = getValueAt(segIdx + 1) + + val dt = t1 - t0 + if (dt <= 1e-9) { + return v0 + } + + val factor = (targetTimestamp - t0) / dt + return v0 + factor * (v1 - v0) + } + + /** + * 버퍼의 모든 데이터를 Pair 리스트로 스냅샷 추출 (테스트 및 디버그용) + */ + fun toList(): List> { + val count = size + val list = ArrayList>(count) + for (i in 0 until count) { + list.add(Pair(getTimestampAt(i), getValueAt(i))) + } + return list + } + + /** + * 버퍼 전체 초기화 + */ + @Synchronized + fun clear() { + head = 0 + size = 0 + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/WorkspacePageState.kt b/Android/app/src/main/java/com/dct/app/domain/model/WorkspacePageState.kt new file mode 100644 index 0000000..0d3bd91 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/WorkspacePageState.kt @@ -0,0 +1,36 @@ +package com.dct.app.domain.model + +import com.dct.app.domain.model.chart.ChartSignalSeries +import com.dct.app.domain.model.chart.TimeWindowPreset +import com.dct.app.presentation.mdi.MdiWindow + +/** + * 멀티 페이지 워크스페이스 탭 상태 도메인 모델 + * + * @param id 탭의 고유 식별자 (예: "page_1", "page_2") + * @param title 탭 헤더에 표시될 제목 (예: "Page 1", "CAN DBC Monitor") + * @param boundSignals 해당 페이지의 멀티축 차트에 바인딩된 신호 시리즈 목록 + * @param alphanumericalSignals 해당 페이지의 Alphanumerical 디지털 계기판에 등록된 신호 ID 목록 + * @param windows 해당 페이지에 열려있는 MDI 서브윈도우 목록 + * @param timeWindowSec 차트 시간 축 가시 범위 (초 단위, 기본 10초) + * @param timeWindowPreset 차트 시간 축 프리셋 + * @param mdbSearchQuery MDB 신호 검색 필터 문자열 + * @param canSearchQuery CAN 신호 검색 필터 문자열 + * @param createdAt 페이지 생성 타임스탬프 + */ +data class WorkspacePageState( + val id: String, + val title: String, + val boundSignals: List = emptyList(), + val alphanumericalSignals: List = emptyList(), + val windows: List = emptyList(), + val timeWindowSec: Float = 10f, + val timeWindowPreset: TimeWindowPreset = TimeWindowPreset.SEC_10, + val mdbSearchQuery: String = "", + val canSearchQuery: String = "", + val createdAt: Long = System.currentTimeMillis(), + val activeWindowId: String? = null, + val scrollX: Float = 0f, + val scrollY: Float = 0f, + val hasSavedLayout: Boolean = false +) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/a2l/A2lDataType.kt b/Android/app/src/main/java/com/dct/app/domain/model/a2l/A2lDataType.kt new file mode 100644 index 0000000..49d4801 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/a2l/A2lDataType.kt @@ -0,0 +1,175 @@ +package com.dct.app.domain.model.a2l + +import com.dct.app.domain.model.mdb.MdbAttrType + +/** + * ASAM MCD-2MC (ASAP2) 표준 12대 데이터 타입 열거형 + * PC 레거시 A2L_DATA_TYPE (xcp_item.h) 100% 호환 + */ +enum class A2lDataType( + val code: Int, + val typeName: String, + val byteSize: Int, + val isSigned: Boolean, + val isFloat: Boolean = false +) { + UBYTE(0, "UBYTE", 1, false), + SBYTE(1, "SBYTE", 1, true), + UWORD(2, "UWORD", 2, false), + SWORD(3, "SWORD", 2, true), + ULONG(4, "ULONG", 4, false), + SLONG(5, "SLONG", 4, true), + UINT64(6, "UINT64", 8, false), + INT64(7, "INT64", 8, true), + FLOAT16_IEEE(8, "FLOAT16_IEEE", 2, true, isFloat = true), + FLOAT32_IEEE(9, "FLOAT32_IEEE", 4, true, isFloat = true), + FLOAT64_IEEE(10, "FLOAT64_IEEE", 8, true, isFloat = true), + FLOAT32_TASKING(11, "FLOAT32_TASKING", 4, true, isFloat = true); + + /** + * MdbAttrType과의 매핑 호환 + */ + fun toMdbAttrType(): MdbAttrType { + return when (this) { + UBYTE -> MdbAttrType.ATTR_0_UINT8 + SBYTE -> MdbAttrType.ATTR_1_INT8 + UWORD -> MdbAttrType.ATTR_2_UINT16 + SWORD -> MdbAttrType.ATTR_3_INT16 + ULONG -> MdbAttrType.ATTR_4_UINT32 + SLONG -> MdbAttrType.ATTR_5_INT32 + else -> MdbAttrType.ATTR_4_UINT32 + } + } + + /** + * 바이너리 바이트 배열로부터 Raw 수치 언패킹 + */ + fun unpackRawValue(bytes: ByteArray, offset: Int, isMsbFirst: Boolean): Double { + if (offset + byteSize > bytes.size) return 0.0 + + return when (this) { + UBYTE -> (bytes[offset].toInt() and 0xFF).toDouble() + SBYTE -> bytes[offset].toDouble() + UWORD -> { + val b0 = bytes[offset].toInt() and 0xFF + val b1 = bytes[offset + 1].toInt() and 0xFF + val raw = if (isMsbFirst) (b0 shl 8) or b1 else (b1 shl 8) or b0 + raw.toDouble() + } + SWORD -> { + val b0 = bytes[offset].toInt() and 0xFF + val b1 = bytes[offset + 1].toInt() and 0xFF + val raw = if (isMsbFirst) (b0 shl 8) or b1 else (b1 shl 8) or b0 + raw.toShort().toDouble() + } + ULONG -> { + val b0 = bytes[offset].toLong() and 0xFF + val b1 = bytes[offset + 1].toLong() and 0xFF + val b2 = bytes[offset + 2].toLong() and 0xFF + val b3 = bytes[offset + 3].toLong() and 0xFF + val raw = if (isMsbFirst) { + (b0 shl 24) or (b1 shl 16) or (b2 shl 8) or b3 + } else { + (b3 shl 24) or (b2 shl 16) or (b1 shl 8) or b0 + } + raw.toDouble() + } + SLONG -> { + val b0 = bytes[offset].toInt() and 0xFF + val b1 = bytes[offset + 1].toInt() and 0xFF + val b2 = bytes[offset + 2].toInt() and 0xFF + val b3 = bytes[offset + 3].toInt() and 0xFF + val raw = if (isMsbFirst) { + (b0 shl 24) or (b1 shl 16) or (b2 shl 8) or b3 + } else { + (b3 shl 24) or (b2 shl 16) or (b1 shl 8) or b0 + } + raw.toDouble() + } + UINT64 -> { + var raw = 0L + for (i in 0 until 8) { + val b = bytes[offset + i].toLong() and 0xFF + val shift = if (isMsbFirst) (7 - i) * 8 else i * 8 + raw = raw or (b shl shift) + } + if (raw >= 0) raw.toDouble() + else (raw and Long.MAX_VALUE).toDouble() + 9_223_372_036_854_775_808.0 + } + INT64 -> { + var raw = 0L + for (i in 0 until 8) { + val b = bytes[offset + i].toLong() and 0xFF + val shift = if (isMsbFirst) (7 - i) * 8 else i * 8 + raw = raw or (b shl shift) + } + raw.toDouble() + } + FLOAT32_IEEE, FLOAT32_TASKING -> { + val b0 = bytes[offset].toInt() and 0xFF + val b1 = bytes[offset + 1].toInt() and 0xFF + val b2 = bytes[offset + 2].toInt() and 0xFF + val b3 = bytes[offset + 3].toInt() and 0xFF + val bits = if (isMsbFirst) { + (b0 shl 24) or (b1 shl 16) or (b2 shl 8) or b3 + } else { + (b3 shl 24) or (b2 shl 16) or (b1 shl 8) or b0 + } + java.lang.Float.intBitsToFloat(bits).toDouble() + } + FLOAT64_IEEE -> { + var bits = 0L + for (i in 0 until 8) { + val b = bytes[offset + i].toLong() and 0xFF + val shift = if (isMsbFirst) (7 - i) * 8 else i * 8 + bits = bits or (b shl shift) + } + java.lang.Double.longBitsToDouble(bits) + } + FLOAT16_IEEE -> { + val b0 = bytes[offset].toInt() and 0xFF + val b1 = bytes[offset + 1].toInt() and 0xFF + val hbits = if (isMsbFirst) (b0 shl 8) or b1 else (b1 shl 8) or b0 + halfToFloat(hbits).toDouble() + } + } + } + + companion object { + fun fromString(str: String): A2lDataType { + val upper = str.trim().uppercase() + return entries.find { it.typeName == upper } + ?: when (upper) { + "BYTE", "UINT8" -> UBYTE + "INT8", "CHAR" -> SBYTE + "WORD", "UINT16" -> UWORD + "INT16", "SHORT" -> SWORD + "DWORD", "LONG", "UINT32" -> ULONG + "INT32", "INT" -> SLONG + "FLOAT", "FLOAT32" -> FLOAT32_IEEE + "DOUBLE", "FLOAT64" -> FLOAT64_IEEE + else -> UBYTE + } + } + + private fun halfToFloat(hbits: Int): Float { + var mant = hbits and 0x03ff + var exp = hbits and 0x7c00 + if (exp == 0x7c00) exp = 0x3fc00 + else if (exp != 0) { + exp += 0x1c000 + if (mant == 0 && exp == 0x1c000) return java.lang.Float.intBitsToFloat((hbits and 0x8000) shl 16) + } else if (mant != 0) { + exp = 0x1c400 + do { + mant = mant shl 1 + exp -= 0x400 + } while ((mant and 0x400) == 0) + mant = mant and 0x3ff + } + return java.lang.Float.intBitsToFloat( + ((hbits and 0x8000) shl 16) or ((exp or mant) shl 13) + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/AxisPosition.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/AxisPosition.kt new file mode 100644 index 0000000..19e2074 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/AxisPosition.kt @@ -0,0 +1,34 @@ +package com.dct.app.domain.model.chart + +/** + * 멀티축 차트의 Y축 위치 정의 (좌측 최대 4개, 우측 최대 4개 총 8개 지원) + * PC C++ 원본(MAX_LEFT_AXIS = 4, MAX_RIGHT_AXIS = 4)의 구조를 정확히 준수합니다. + */ +enum class AxisPosition( + val isLeft: Boolean, + val index: Int, + val displayName: String +) { + LEFT_0(true, 0, "Left Axis 1"), + LEFT_1(true, 1, "Left Axis 2"), + LEFT_2(true, 2, "Left Axis 3"), + LEFT_3(true, 3, "Left Axis 4"), + RIGHT_0(false, 0, "Right Axis 1"), + RIGHT_1(false, 1, "Right Axis 2"), + RIGHT_2(false, 2, "Right Axis 3"), + RIGHT_3(false, 3, "Right Axis 4"); + + companion object { + val LEFT_AXES = listOf(LEFT_0, LEFT_1, LEFT_2, LEFT_3) + val RIGHT_AXES = listOf(RIGHT_0, RIGHT_1, RIGHT_2, RIGHT_3) + + fun from(isLeft: Boolean, index: Int): AxisPosition { + val clampedIdx = index.coerceIn(0, 3) + return if (isLeft) { + LEFT_AXES[clampedIdx] + } else { + RIGHT_AXES[clampedIdx] + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisConfig.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisConfig.kt new file mode 100644 index 0000000..2e7134d --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisConfig.kt @@ -0,0 +1,47 @@ +package com.dct.app.domain.model.chart + +/** + * 개별 Y축 설정 모델 (PC C++ CChartAxis 및 MultiXGraphWnd 축 설정 반영) + * + * @param position 축의 위치 (LEFT_0..3, RIGHT_0..3) + * @param minVal Y축 최소 표시값 (기본값 -50.0) + * @param maxVal Y축 최대 표시값 (기본값 100.0) + * @param isAuto 자동 스케일링 여부 (ScreenAutomatic) + * @param color 축 라벨 및 눈금 색상 (ARGB Long) + * @param unit 물리 단위 문자열 (예: "RPM", "°C", "V", "%") + * @param label 축 설명 레이블 + * @param tickCount 눈금 분할 개수 (기본 5개) + * @param offsetDp 축 간의 겹침 방지 오프셋 (PC SetMarginSize 20+i*20 반영) + * @param isVisible 축 표시 여부 + */ +data class ChartAxisConfig( + val position: AxisPosition, + val minVal: Double = -50.0, + val maxVal: Double = 100.0, + val isAuto: Boolean = true, + val color: Long = 0xFF172B4DL, + val unit: String = "", + val label: String = "", + val tickCount: Int = 5, + val offsetDp: Float = 0f, + val isVisible: Boolean = true +) { + init { + require(maxVal >= minVal) { "maxVal($maxVal)은 minVal($minVal)보다 크거나 같아야 합니다." } + } + + /** + * 유효한 스팬 (0 나누기 방어) + */ + val span: Double get() { + val diff = maxVal - minVal + return if (diff.isFinite() && diff > 1e-9) diff else 1.0 + } + + /** + * 특정 축 값에 따른 눈금(Tick) 리스트 생성 (PC CChartStandardAxis 알고리즘 반영) + */ + fun generateTicks(heightPx: Float = 300f): List { + return MultiAxisCoordinateTransformer.generateStandardTicks(minVal, maxVal, heightPx) + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisTicks.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisTicks.kt new file mode 100644 index 0000000..0e4f709 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisTicks.kt @@ -0,0 +1,158 @@ +package com.dct.app.domain.model.chart + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.* + +/** Shared by the real Canvas and coordinate-transform tests. Pixel units match the PC axes. */ +object ChartAxisTicks { + private const val LIMIT = 4096 + data class Interval(val unit: Unit, val multiple: Int = 1) { + val seconds: Double get() = unit.seconds * multiple + val pattern: String get() = when (unit) { + Unit.MILLISECOND -> "HH:mm:ss.SSS" + Unit.SECOND -> "HH:mm:ss" + Unit.MINUTE -> "HH:mm" + Unit.HOUR -> "HH:00" + Unit.DAY -> "dd MMM" + Unit.MONTH -> "MMM yyyy" + Unit.YEAR -> "yyyy" + } + } + enum class Unit(val seconds: Double) { + MILLISECOND(0.001), SECOND(1.0), MINUTE(60.0), HOUR(3600.0), + DAY(86400.0), MONTH(86400.0 * 30), YEAR(86400.0 * 365) + } + data class TimeTicks(val values: List, val interval: Interval) { + fun label(value: Double, zone: ZoneId = ZoneId.systemDefault()): String = + DateTimeFormatter.ofPattern(interval.pattern, Locale.US) + .format(Instant.ofEpochMilli((value * 1000).roundToLong()).atZone(zone)) + } + data class NumericTicks(val values: List, val step: Double) { + fun label(value: Double): String { + val decimals = (-floor(log10(step))).toInt().coerceAtLeast(0) + if (decimals > 10 || abs(value) >= 1e12) return String.format(Locale.US, "%.6g", value) + return java.math.BigDecimal.valueOf(if (abs(value) < step * 1e-8) 0.0 else value) + .setScale(decimals, java.math.RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + } + } + + /** CChartDateTimeAxis::RefreshTickIncrement, plus milliseconds for tablet pinch zoom. */ + fun automaticInterval(span: Double, widthPx: Float, pixelSpace: Int = 60): Interval { + val count = (widthPx / pixelSpace.coerceAtLeast(1)).toInt().coerceIn(1, LIMIT / 2) + val raw = (span / count).takeIf { it.isFinite() && it > 0 } ?: 1.0 + return when { + raw < 1 -> Interval(Unit.MILLISECOND, (niceStep(raw.coerceAtLeast(.001)) * 1000).roundToInt().coerceAtLeast(1)) + raw <= 2 -> Interval(Unit.SECOND) + raw <= 5 -> Interval(Unit.SECOND, 5) + raw <= 10 -> Interval(Unit.SECOND, 10) + raw <= 30 -> Interval(Unit.SECOND, 30) + raw < 60 -> Interval(Unit.MINUTE) + raw <= 120 -> Interval(Unit.MINUTE, 2) + raw <= 300 -> Interval(Unit.MINUTE, 5) + raw <= 600 -> Interval(Unit.MINUTE, 10) + raw <= 1800 -> Interval(Unit.MINUTE, 30) + raw < 3600 -> Interval(Unit.HOUR) + raw <= 7200 -> Interval(Unit.HOUR, 2) + raw <= 21600 -> Interval(Unit.HOUR, 6) + raw <= 43200 -> Interval(Unit.HOUR, 12) + raw < 86400 -> Interval(Unit.DAY) + raw <= 172800 -> Interval(Unit.DAY, 2) + raw <= 604800 -> Interval(Unit.DAY, 7) + raw < 31 * 86400.0 -> Interval(Unit.MONTH) + raw <= 62 * 86400.0 -> Interval(Unit.MONTH, 2) + raw <= 124 * 86400.0 -> Interval(Unit.MONTH, 4) + raw <= 186 * 86400.0 -> Interval(Unit.MONTH, 6) + raw < 365 * 86400.0 -> Interval(Unit.YEAR) + else -> Interval(Unit.YEAR, (raw / (365 * 86400.0)).toInt().coerceIn(0, 100000) + 1) + } + } + + fun time(min: Double, max: Double, widthPx: Float, + preset: TimeWindowPreset = TimeWindowPreset.AUTO, pixelSpace: Int = 60, + zone: ZoneId = ZoneId.systemDefault()): TimeTicks { + val fixed = preset.tickIntervalSec + val interval = if (fixed == null) automaticInterval(max - min, widthPx, pixelSpace) + else if (fixed < 60) Interval(Unit.SECOND, fixed.toInt()) + else Interval(Unit.MINUTE, (fixed / 60).toInt()) + if (!validRange(min, max, widthPx) || abs(min) > 1e12 || abs(max) > 1e12) + return TimeTicks(emptyList(), interval) + if (interval.unit <= Unit.HOUR) { + val origin = Instant.ofEpochMilli((min * 1000).toLong()).atZone(zone) + .toLocalDate().atStartOfDay(zone).toEpochSecond().toDouble() + return TimeTicks(aligned(min, max, interval.seconds, origin), interval) + } + // Days/months/years use calendar boundaries, not fixed 30/365-day approximations. + val date = Instant.ofEpochMilli((min * 1000).toLong()).atZone(zone).toLocalDate() + val n = interval.multiple.toLong() + var tick = when (interval.unit) { + Unit.DAY -> { + val reference = LocalDate.of(2000, 1, 1).toEpochDay() + LocalDate.ofEpochDay(reference + Math.floorDiv(date.toEpochDay() - reference, n) * n) + } + Unit.MONTH -> { + val month = Math.floorDiv(date.year.toLong() * 12 + date.monthValue - 1, n) * n + LocalDate.of(Math.floorDiv(month, 12).toInt(), Math.floorMod(month, 12) + 1, 1) + } + else -> LocalDate.of(2000 + Math.floorDiv(date.year - 2000, interval.multiple) * interval.multiple, 1, 1) + } + val values = ArrayList() + repeat(LIMIT) { + val value = tick.atStartOfDay(zone).toEpochSecond().toDouble() + if (value > max) return TimeTicks(values, interval) + if (value >= min) values.add(value) + tick = when (interval.unit) { + Unit.DAY -> tick.plusDays(n) + Unit.MONTH -> tick.plusMonths(n) + else -> tick.plusYears(n) + } + } + return TimeTicks(values, interval) + } + + fun numeric(min: Double, max: Double, widthPx: Float, pixelSpace: Int = 30): NumericTicks { + if (!validRange(min, max, widthPx)) return NumericTicks(emptyList(), 1.0) + val count = (widthPx / pixelSpace.coerceAtLeast(1)).toInt().coerceIn(1, LIMIT / 2) + val step = niceStep((max - min) / count) + return NumericTicks(aligned(min, max, step), step) + } + + private fun validRange(min: Double, max: Double, width: Float) = + min.isFinite() && max.isFinite() && (max - min).isFinite() && max > min && width.isFinite() && width > 0 + + private fun niceStep(raw: Double): Double { + val base = 10.0.pow(floor(log10(raw))) + return listOf(1.0, 2.0, 5.0, 10.0).firstOrNull { base * it >= raw }?.times(base) ?: raw + } + + private fun aligned(min: Double, max: Double, step: Double, origin: Double = 0.0): List { + if (!step.isFinite() || step <= 0) return emptyList() + val tolerance = maxOf(step * 1e-8, Math.ulp(maxOf(abs(min), abs(max))) * 2) + val first = ceil((min - origin - tolerance) / step) + return (0 until LIMIT).asSequence().map { origin + (first + it) * step } + .takeWhile { it <= max + tolerance }.filter { it >= min - tolerance } + .map { it.coerceIn(min, max) }.distinct().toList() + } +} + +data class AxisLabelPlacement(val index: Int, val left: Float) + +/** Keep every tick/grid line; omit only labels that would overlap or leave the plot. */ +fun placeAxisLabels(centers: List, widths: List, left: Float, right: Float, + gap: Float = 4f): List { + val result = ArrayList() + var previousRight = Float.NEGATIVE_INFINITY + centers.forEachIndexed { index, center -> + val width = widths.getOrNull(index) ?: return@forEachIndexed + if (!center.isFinite() || !width.isFinite() || width < 0 || width > right - left) return@forEachIndexed + val x = (center - width / 2).coerceIn(left, right - width) + if (x >= previousRight + gap) { + result.add(AxisLabelPlacement(index, x)) + previousRight = x + width + } + } + return result +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartDrawingRules.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartDrawingRules.kt new file mode 100644 index 0000000..aa0075e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartDrawingRules.kt @@ -0,0 +1,33 @@ +package com.dct.app.domain.model.chart + +fun isValidChartAxisRange(min: Double, max: Double): Boolean = + min.isFinite() && max.isFinite() && max > min && (max - min).isFinite() + +/** Round-trip precision for an editable bound, not a rounded display label. */ +fun chartAxisInputText(value: Double): String = if (value.isFinite()) value.toString() else "" + +fun chartAxisTickText(value: Double, ticks: List): String { + val step = ticks.zipWithNext { a, b -> b - a }.filter { it > 0 && it.isFinite() }.minOrNull() + ?: return chartAxisInputText(value) + return ChartAxisTicks.NumericTicks(ticks, step).label(value) +} + +/** The PC-style Thickness menu is expressed in physical pixels, despite the legacy field name. */ +fun chartLineWidthPx(value: Float): Float = if (value.isFinite()) value.coerceIn(1f, 5f) else 1f + +/** Preserve data coordinates. Only the Canvas clip rectangle may cut the resulting line. */ +inline fun forEachChartVertex(points: List>, mode: InterpolationMode, + emit: (time: Double, value: Double, move: Boolean) -> Unit) { + var start = true + var previousValue = 0.0 + for ((time, value) in points) { + if (!time.isFinite() || !value.isFinite()) { + start = true + continue + } + if (!start && mode == InterpolationMode.PWM_STEP) emit(time, previousValue, false) + emit(time, value, start) + previousValue = value + start = false + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartRenderSnapshot.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartRenderSnapshot.kt new file mode 100644 index 0000000..09f56cd --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartRenderSnapshot.kt @@ -0,0 +1,39 @@ +package com.dct.app.domain.model.chart + +/** + * 렌더링을 위해 가공된 단일 신호 시리즈의 표시용 스냅샷 + */ +data class SeriesRenderData( + val signalId: String, + val signalName: String, + val axisPosition: AxisPosition, + val color: Long, + val unit: String, + val lineWidthDp: Float, + val dashStyle: Int = 0, + val interpolationMode: InterpolationMode, + val isVisible: Boolean, + val points: List>, // (TimestampSec, Value) + val latestValue: Double?, + val minVisibleValue: Double?, + val maxVisibleValue: Double?, + // Captured from original samples when the cursor is selected, never from display points. + val cursorValue: Double? = null +) + +/** + * 차트 캔버스가 1 프레임 렌더링에 필요한 전체 불변 상태 스냅샷 + */ +data class ChartRenderSnapshot( + val timeMinSec: Double, + val timeMaxSec: Double, + val seriesList: List, + val axesConfig: Map, + val crosshairTimeSec: Double? = null, + val isPaused: Boolean = false, + val legendPosition: Int = 0, // 0: Right, 1: Top, 2: Bottom + val isXAxisAuto: Boolean = true, + val xAxisPreset: TimeWindowPreset = TimeWindowPreset.SEC_30, + val isYAxisAuto: Boolean = true, + val timestampMs: Long = System.currentTimeMillis() +) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartSignalSeries.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartSignalSeries.kt new file mode 100644 index 0000000..cdd469f --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartSignalSeries.kt @@ -0,0 +1,30 @@ +package com.dct.app.domain.model.chart + +import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer + +/** + * 차트에 바인딩된 단일 신호 시리즈 모델 + * + * @param signalId 고유 신호 식별자 + * @param signalName 화면에 표시될 신호명 + * @param axisPosition 바인딩된 Y축 위치 (LEFT_0..3, RIGHT_0..3) + * @param color 파형 렌더링 색상 (ARGB Long) + * @param unit 물리 단위 + * @param lineWidthDp 선 두께 (dp: 1.0f ~ 5.0f) + * @param dashStyle 선 스타일 (0: Solid, 1: Dash, 2: Dot, 3: Dash dot, 4: Dash dot dot) + * @param isVisible 파형 표시 여부 + * @param interpolationMode 보간 모드 (LINEAR, PWM_STEP) + * @param buffer 신호 시계열 데이터를 보관하는 고성능 링버퍼 + */ +data class ChartSignalSeries( + val signalId: String, + val signalName: String, + val axisPosition: AxisPosition, + val color: Long, + val unit: String = "", + val lineWidthDp: Float = 2.0f, + val dashStyle: Int = 0, + val isVisible: Boolean = true, + val interpolationMode: InterpolationMode = InterpolationMode.LINEAR, + val buffer: TimeSeriesRingBuffer = TimeSeriesRingBuffer(100_000) +) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartValueInterpolation.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartValueInterpolation.kt new file mode 100644 index 0000000..e7c4f46 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/ChartValueInterpolation.kt @@ -0,0 +1,26 @@ +package com.dct.app.domain.model.chart + +/** Values at the cursor follow the same linear/hold-previous rule as the plotted path. */ +fun interpolateChartValue( + points: List>, + timeSec: Double, + mode: InterpolationMode +): Double? { + if (points.isEmpty() || !timeSec.isFinite()) return null + if (timeSec <= points.first().first) return points.first().second + if (timeSec >= points.last().first) return points.last().second + + var low = 0 + var high = points.lastIndex + while (low < high) { + val mid = (low + high) ushr 1 + if (points[mid].first < timeSec) low = mid + 1 else high = mid + } + val (nextTime, nextValue) = points[low] + // At the transition timestamp Pulse must show the new sample, not the previous one. + if (timeSec == nextTime) return nextValue + val (previousTime, previousValue) = points[low - 1] + if (mode == InterpolationMode.PWM_STEP) return previousValue + val ratio = (timeSec - previousTime) / (nextTime - previousTime) + return previousValue + ratio * (nextValue - previousValue) +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt new file mode 100644 index 0000000..ab40ec1 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt @@ -0,0 +1,208 @@ +package com.dct.app.domain.model.chart + +import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer +import kotlin.math.max +import kotlin.math.min + +/** + * 고속 차트 가시 영역 탐색 및 Min-Max 다운샘플링 알고리즘 + * + * - PC C++ CChartPointsArray::GetVisiblePoints 알고리즘을 완벽히 이식. + * - 단조 증가 타임스탬프의 O(log N) 이진 탐색 클리핑 및 1-Point Boundary Expansion 지원. + * - 픽셀 컬럼 버킷 기반 Min-Max 다운샘플링으로 극값(Peak/Valley) 및 PWM 엣지를 100% 보존합니다. + */ +object DownsamplingAlgorithms { + + /** + * 시간 윈도우 [tMin, tMax]에 속하는 가시 범위의 시작/끝 인덱스를 O(log N)으로 탐색 + * + * @param buffer 시계열 링버퍼 + * @param tMin 표시 영역 시작 타임스탬프 (초) + * @param tMax 표시 영역 종료 타임스탬프 (초) + * @return Pair(expandedFirstIdx, expandedLastIdx) 또는 가시 영역 밖일 경우 null + */ + fun findVisibleIndexRange( + buffer: TimeSeriesRingBuffer, + tMin: Double, + tMax: Double + ): Pair? { + val count = buffer.size + if (count == 0) return null + + val oldestT = buffer.getTimestampAt(0) + val newestT = buffer.getTimestampAt(count - 1) + + // 가시 영역과 전혀 겹치지 않는 경우 클리핑 + if (newestT < tMin || oldestT > tMax) { + return null + } + + // 1. Lower Bound (tMin 이상인 첫 번째 인덱스) + var low = 0 + var high = count - 1 + var firstIdx = count - 1 + + while (low <= high) { + val mid = (low + high) ushr 1 + if (buffer.getTimestampAt(mid) >= tMin) { + firstIdx = mid + high = mid - 1 + } else { + low = mid + 1 + } + } + + // 2. Upper Bound (tMax 이하인 마지막 번째 인덱스) + low = firstIdx + high = count - 1 + var lastIdx = firstIdx + + while (low <= high) { + val mid = (low + high) ushr 1 + if (buffer.getTimestampAt(mid) <= tMax) { + lastIdx = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + + if (firstIdx > lastIdx) { + firstIdx = lastIdx + } + + // 3. 1-Point Boundary Expansion (화면 외곽까지 매끄럽게 연결) + val expandedFirst = if (firstIdx > 0) firstIdx - 1 else 0 + val expandedLast = if (lastIdx < count - 1) lastIdx + 1 else count - 1 + + return Pair(expandedFirst, expandedLast) + } + + /** + * 화면 픽셀 기반 적응형 다운샘플링 + * - LINEAR 모드: 픽셀 내 수직 절벽(펄스화) 현상을 방지하기 위해 버킷당 시간 순서 보존(최대 6,000개 원본 패스스루) + * - PWM_STEP 모드: 펄스/스텝 신호의 상/하한 엣지 보존을 위한 Min-Max 추출 + * + * @param buffer 시계열 링버퍼 + * @param firstIdx 가시 영역 시작 인덱스 + * @param lastIdx 가시 영역 끝 인덱스 + * @param tMin 가시 영역 최소 시간(초) + * @param tMax 가시 영역 최대 시간(초) + * @param screenWidthPx 화면 너비(픽셀) + * @param mode 보간 모드 (LINEAR vs PWM_STEP) + * @return 다운샘플링된 (타임스탬프, 물리값) 리스트 + */ + fun minMaxDownsample( + buffer: TimeSeriesRingBuffer, + firstIdx: Int, + lastIdx: Int, + tMin: Double, + tMax: Double, + screenWidthPx: Int, + mode: InterpolationMode = InterpolationMode.LINEAR + ): List> { + if (buffer.size == 0) return emptyList() + if (firstIdx < 0 || lastIdx < 0 || firstIdx >= buffer.size || lastIdx >= buffer.size || firstIdx > lastIdx) { + return emptyList() + } + val totalPoints = (lastIdx - firstIdx + 1).coerceAtLeast(0) + if (totalPoints == 0) return emptyList() + + val width = max(10, screenWidthPx) + // 모바일 GPU 가속 여유를 고려하여 Linear 모드에서는 최대 6,000개(또는 화면 폭 4배)까지 원본 1:1 패스스루 + val passThroughLimit = if (mode == InterpolationMode.LINEAR) max(width * 4, 6000) else (width * 2) + if (totalPoints <= passThroughLimit) { + val result = ArrayList>(totalPoints) + for (i in firstIdx..lastIdx) { + result.add(Pair(buffer.getTimestampAt(i), buffer.getValueAt(i))) + } + return result + } + + val result = ArrayList>(width * 4) + val tSpan = if (tMax - tMin > 1e-9) tMax - tMin else 1.0 + + // 버킷 단위로 샘플링 + var currentBucket = -1 + var firstInBucket: Pair? = null + var minInBucket: Pair? = null + var maxInBucket: Pair? = null + var lastInBucket: Pair? = null + var minTime = 0.0 + var maxTime = 0.0 + + fun flushBucket() { + if (firstInBucket != null) { + val f = firstInBucket!! + val mn = minInBucket ?: f + val mx = maxInBucket ?: f + val l = lastInBucket ?: f + + result.add(f) + + if (mode == InterpolationMode.LINEAR) { + // Linear 모드: 1픽셀 내 인위적 Min-Max 왕복으로 인한 수직 절벽선(Pulse화) 방지 + // 시간 발생 순서에 따라 중간 극값 1개만 배치하거나 끝점만 연결하여 시간 순서 보존 + if (minTime != maxTime) { + if (minTime < maxTime) { + if (mn != f && mn != l) result.add(mn) + if (mx != f && mx != l && mx != mn) result.add(mx) + } else { + if (mx != f && mx != l) result.add(mx) + if (mn != f && mn != l && mn != mx) result.add(mn) + } + } + if (l != f && l != mn && l != mx) { + result.add(l) + } + } else { + // PWM_STEP (Pulse 모드): 스텝 신호의 상하한 엣지를 명확히 표현 + if (minTime < maxTime) { + if (mn != f && mn != l) result.add(mn) + if (mx != f && mx != l && mx != mn) result.add(mx) + } else { + if (mx != f && mx != l) result.add(mx) + if (mn != f && mn != l && mn != mx) result.add(mn) + } + if (l != f && l != mn && l != mx) { + result.add(l) + } + } + } + firstInBucket = null + minInBucket = null + maxInBucket = null + lastInBucket = null + } + + for (i in firstIdx..lastIdx) { + val t = buffer.getTimestampAt(i) + val v = buffer.getValueAt(i) + val bucket = (((t - tMin) / tSpan) * width).toInt().coerceIn(0, width - 1) + + if (bucket != currentBucket) { + flushBucket() + currentBucket = bucket + firstInBucket = Pair(t, v) + minInBucket = Pair(t, v) + maxInBucket = Pair(t, v) + lastInBucket = Pair(t, v) + minTime = t + maxTime = t + } else { + lastInBucket = Pair(t, v) + if (v < (minInBucket?.second ?: Double.MAX_VALUE)) { + minInBucket = Pair(t, v) + minTime = t + } + if (v > (maxInBucket?.second ?: -Double.MAX_VALUE)) { + maxInBucket = Pair(t, v) + maxTime = t + } + } + } + flushBucket() + + return result + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/InterpolationMode.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/InterpolationMode.kt new file mode 100644 index 0000000..b2dd91c --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/InterpolationMode.kt @@ -0,0 +1,16 @@ +package com.dct.app.domain.model.chart + +/** + * 신호 파형 렌더링 보간 모드 + */ +enum class InterpolationMode { + /** + * 선형 보간 (Linear Interpolation): RPM, 온도, 압력 등 연속 아날로그 신호 + */ + LINEAR, + + /** + * 계단형 직각 보간 (PWM / Step Staircase): 밸브 On/Off, 디지털 비트 신호 + */ + PWM_STEP +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/MultiAxisCoordinateTransformer.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/MultiAxisCoordinateTransformer.kt new file mode 100644 index 0000000..c0a4efd --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/MultiAxisCoordinateTransformer.kt @@ -0,0 +1,208 @@ +package com.dct.app.domain.model.chart + +/** + * 플로팅 차트 영역의 픽셀 경계 사각형 + */ +data class PlotBounds( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) { + val isDrawable: Boolean get() = left.isFinite() && top.isFinite() && right.isFinite() && bottom.isFinite() && right > left && bottom > top + val width: Float get() = (right - left).coerceAtLeast(1f) + val height: Float get() = (bottom - top).coerceAtLeast(1f) +} + +/** + * 좌측 최대 4개 독립 Y축 및 공통 시간 X축 양방향 좌표 변환기 (Multi-Axis Coordinate Transformer) + * - PC C++ CChartStandardAxis (1-2-5-10 스마트 정수 틱 스텝 및 배수 정렬) + * - PC C++ CChartDateTimeAxis (절대시각 HH:mm:ss 30초/1분 정수 스냅 정렬) + * - PC C++ CChartGrid (3px/3px 점선 패턴, RGB 128, 128, 128) + */ +object MultiAxisCoordinateTransformer { + + const val MAX_LEFT_AXIS = 4 + const val DEFAULT_AXIS_WIDTH_PX = 54f + + /** + * 시간(t) -> 스크린 X 픽셀 변환 (공통 시간축) + */ + fun timeToScreenX( + timeSec: Double, + tMinSec: Double, + tMaxSec: Double, + plotLeft: Float, + plotWidth: Float + ): Float { + val tSpan = if (tMaxSec - tMinSec > 1e-9) tMaxSec - tMinSec else 1.0 + val ratio = (timeSec - tMinSec) / tSpan + return plotLeft + (ratio * plotWidth).toFloat() + } + + /** + * 스크린 X 픽셀 -> 시간(t) 역변환 + */ + fun screenXToTime( + screenX: Float, + tMinSec: Double, + tMaxSec: Double, + plotLeft: Float, + plotWidth: Float + ): Double { + val width = if (plotWidth > 1e-3f) plotWidth else 1.0f + val ratio = (screenX - plotLeft) / width + val tSpan = if (tMaxSec - tMinSec > 1e-9) tMaxSec - tMinSec else 1.0 + return tMinSec + ratio * tSpan + } + + /** + * 특정 축의 물리값(v) -> 스크린 Y 픽셀 변환 (상단이 Y=0, 하단이 Y=H) + */ + fun valueToScreenY( + value: Double, + vMin: Double, + vMax: Double, + plotTop: Float, + plotHeight: Float + ): Float { + val vSpan = if (vMax - vMin > 1e-9) vMax - vMin else 1.0 + val ratio = (value - vMin) / vSpan + val plotBottom = plotTop + plotHeight + return plotBottom - (ratio * plotHeight).toFloat() + } + + /** + * 스크린 Y 픽셀 -> 특정 축의 물리값(v) 역변환 + */ + fun screenYToValue( + screenY: Float, + vMin: Double, + vMax: Double, + plotTop: Float, + plotHeight: Float + ): Double { + val height = if (plotHeight > 1e-3f) plotHeight else 1.0f + val plotBottom = plotTop + height + val ratio = (plotBottom - screenY) / height + val vSpan = if (vMax - vMin > 1e-9) vMax - vMin else 1.0 + return vMin + ratio * vSpan + } + + /** + * PC CChartStandardAxis 알고리즘: Y축 1, 2, 5, 10 계열 스마트 정수 눈금 생성 + */ + fun generateStandardTicks( + minVal: Double, + maxVal: Double, + heightPx: Float = 300f, + pixelSpace: Int = 20 + ): List { + return ChartAxisTicks.numeric(minVal, maxVal, heightPx, pixelSpace).values + } + + /** + * PC CChartDateTimeAxis 알고리즘: X축 타임스탬프(초) 정수 스냅 눈금 생성 (10s, 30s, 1m, 5m 등) + */ + fun generateTimeTicks( + tMinSec: Double, + tMaxSec: Double, + widthPx: Float = 500f, + pixelSpace: Int = 60 + ): List { + return ChartAxisTicks.time(tMinSec, tMaxSec, widthPx, pixelSpace = pixelSpace).values + } + + /** + * 좌측 축 개수 및 다중축 여부에 따른 좌측 여백(픽셀) 계산 + */ + fun calculateLeftMargin( + leftAxisCount: Int, + isMultiAxis: Boolean = true, + axisWidthPx: Float = DEFAULT_AXIS_WIDTH_PX + ): Float { + if (!isMultiAxis || leftAxisCount <= 1) { + return 58f + } + val count = leftAxisCount.coerceIn(1, MAX_LEFT_AXIS) + return 12f + (count * axisWidthPx) + } + + /** + * 상단 여백 계산: Top(1) 범례 선택 시에만 36px 확보, 그 외에는 12px로 축소하여 차트 최대화 + */ + fun calculateTopMargin( + legendPosition: Int = 0, + hasLegend: Boolean = true + ): Float { + if (hasLegend && legendPosition == 1) { + return 36f + } + return 12f + } + + /** + * 우측 여백 계산: Right(0) 범례 선택 시에만 범례 폭(110~400px) 확보, 그 외에는 16px로 축소하여 차트 최대화 + */ + fun calculateRightMargin( + legendPosition: Int = 0, + hasLegend: Boolean = true, + legendWidthPx: Float = 0f + ): Float { + if (hasLegend && legendPosition == 0) { + val needed = if (legendWidthPx > 0f) legendWidthPx + 28f else 138f + return needed.coerceIn(110f, 400f) + } + return 16f + } + + /** + * 하단 여백 계산: Bottom(2) 범례 선택 시에만 범례 공간 확보(58~84px), 그 외에는 기본 24px로 축소하여 차트 최대화 + */ + fun calculateBottomMargin( + legendPosition: Int = 2, + hasLegend: Boolean = true, + baseBottomMargin: Float = 24f, + seriesCount: Int = 0 + ): Float { + if (hasLegend && legendPosition == 2) { + val legendHeightPx = if (seriesCount >= 3) 54f else 32f + val extraGap = if (seriesCount >= 3) 8f else 4f + return baseBottomMargin + legendHeightPx + extraGap + } + return baseBottomMargin + } + + /** + * 전체 캔버스 크기와 활성 축 설정 및 범례 위치에 따른 플로팅 영역 사각형 계산 + */ + fun calculatePlotBounds( + canvasWidth: Float, + canvasHeight: Float, + leftAxisCount: Int, + isMultiAxis: Boolean = true, + hasLegend: Boolean = true, + legendPosition: Int = 0, + legendWidthPx: Float = 0f, + seriesCount: Int = 0, + axisWidthPx: Float = DEFAULT_AXIS_WIDTH_PX, + rightAxisCount: Int = 0 + ): PlotBounds { + val leftMargin = calculateLeftMargin(leftAxisCount, isMultiAxis, axisWidthPx) + val topMargin = calculateTopMargin(legendPosition, hasLegend) + val rightMargin = calculateRightMargin(legendPosition, hasLegend, legendWidthPx) + + (if (isMultiAxis) rightAxisCount.coerceIn(0, 4) * axisWidthPx else 0f) + val bottomMargin = calculateBottomMargin(legendPosition, hasLegend, seriesCount = seriesCount) + + val width = canvasWidth.takeIf { it.isFinite() }?.coerceAtLeast(0f) ?: 0f + val height = canvasHeight.takeIf { it.isFinite() }?.coerceAtLeast(0f) ?: 0f + val maxAllowedLeft = (width * 0.45f).coerceAtLeast(50f) + val clampedLeft = leftMargin.coerceAtMost(maxAllowedLeft) + val left = clampedLeft.coerceIn(0f, width) + val right = (width - rightMargin).coerceAtLeast(left + 80f).coerceAtMost(width) + val top = topMargin.coerceIn(0f, height) + val bottom = (height - bottomMargin).coerceAtLeast(top + 60f).coerceAtMost(height) + + return PlotBounds(left = left, top = top, right = right, bottom = bottom) + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeAxisViewport.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeAxisViewport.kt new file mode 100644 index 0000000..01a5613 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeAxisViewport.kt @@ -0,0 +1,53 @@ +package com.dct.app.domain.model.chart + +/** Per-window range: fixed presets fill initially, then roll continuously at the live edge. */ +internal class TimeAxisViewport { + data class Range(val min: Double, val max: Double) + private var range: Range? = null + val currentRange: Range? get() = range + private var hadData = false + private var lastSeen: Double? = null + + fun reset() { range = null; hadData = false; lastSeen = null } + + fun pin(min: Double, max: Double, latest: Double? = null) { + range = Range(min, max) + hadData = latest != null + lastSeen = latest + } + + fun resolve(oldest: Double?, latest: Double?, now: Double, span: Double, + preset: TimeWindowPreset, auto: Boolean, follow: Boolean, width: Float): Range { + val previous = range + if (!follow && previous != null) return previous + val hasData = oldest != null && latest != null + val first = oldest ?: now + val last = latest ?: now + val step = preset.tickIntervalSec ?: ChartAxisTicks.automaticInterval( + ((previous?.max ?: last) - first).coerceAtLeast(1.0), width + ).seconds + val next = when { + previous == null || hasData != hadData -> { + if (auto && hasData) Range(first, last + step) + else if (!follow || preset.tickIntervalSec == null) Range(last - span, last) + else { + val right = maxOf(first + span, last) + Range(right - span, right) + } + } + !hasData -> previous + auto -> Range(first, if (last >= previous.max) last + step else previous.max) + latest == lastSeen -> previous + preset.tickIntervalSec != null -> { + // Tick spacing controls labels only, not scrolling. Once the initial range + // fills, keep the latest sample at the right edge without opening a blank tick. + if (last >= previous.max) Range(last - span, last) else previous + } + else -> Range(last - span, last) + } + range = next + hadData = hasData + lastSeen = latest + return next + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeWindowPreset.kt b/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeWindowPreset.kt new file mode 100644 index 0000000..0f0a0d8 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/chart/TimeWindowPreset.kt @@ -0,0 +1,42 @@ +package com.dct.app.domain.model.chart + +/** + * PC X-axis menu values denote tick intervals, not the entire visible duration. + * durationSec retains its serialized/API meaning as the menu's seconds value. + */ +enum class TimeWindowPreset( + val durationSec: Double, + val displayName: String +) { + AUTO(0.0, "Auto"), + SEC_10(10.0, "10s"), + SEC_30(30.0, "30s"), + MIN_1(60.0, "1m"), + MIN_5(300.0, "5m"), + MIN_10(600.0, "10m"), + MANUAL(-1.0, "Manual"); + + val durationMs: Long get() = (durationSec * 1000).toLong() + + val tickIntervalSec: Double? get() = durationSec.takeIf { it > 0.0 } + + /** PC range immediately after selecting an X-axis tick preset. */ + val displaySpanSec: Double get() = when (this) { + SEC_10 -> 40.0 + SEC_30 -> 150.0 + MIN_1 -> 300.0 + MIN_5 -> 1200.0 + MIN_10 -> 2400.0 + else -> 60.0 + } + + /** PC ClearGraphPointData starts with six intervals. */ + val resetSpanSec: Double get() = tickIntervalSec?.times(6.0) ?: 60.0 + + companion object { + fun fromDurationSec(durationSec: Double): TimeWindowPreset { + return entries.firstOrNull { it != AUTO && it != MANUAL && kotlin.math.abs(it.durationSec - durationSec) < 0.1 } + ?: if (durationSec <= 0.0) AUTO else MANUAL + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileAlertState.kt b/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileAlertState.kt new file mode 100644 index 0000000..30e7236 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileAlertState.kt @@ -0,0 +1,10 @@ +package com.dct.app.domain.model.dashboard + +/** + * 대시보드 타일의 임계치 상태 + */ +enum class TileAlertState { + NORMAL, + WARNING, + DANGER +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileWidgetItem.kt b/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileWidgetItem.kt new file mode 100644 index 0000000..fccf40b --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileWidgetItem.kt @@ -0,0 +1,85 @@ +package com.dct.app.domain.model.dashboard + +import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer +import java.util.Locale + +/** + * 실시간 타일 위젯 아이템 도메인 모델 + * + * @param signalId 신호 식별자 + * @param signalName 화면에 표시될 신호명 + * @param value 현재 측정 물리값 + * @param unit 물리 단위 + * @param color 신호 고유 테마 색상 (ARGB Long) + * @param realLen 소수점 자릿수 정밀도 (0: 정수, 1: 소수점 1자리, 2: 2자리, >=3: 3자리) + * @param minLimit 절대 최소 위험 임계치 (Danger) + * @param maxLimit 절대 최대 위험 임계치 (Danger) + * @param warnMinLimit 경고 최소 임계치 (Warning) + * @param warnMaxLimit 경고 최대 임계치 (Warning) + * @param historyBuffer 최근 10~30초간의 스파크라인용 링버퍼 (기본 1000개) + */ +data class TileWidgetItem( + val signalId: String, + val signalName: String, + val value: Double = 0.0, + val unit: String = "", + val color: Long = 0xFF1976D2L, + val realLen: Int = 1, + val minLimit: Double? = null, + val maxLimit: Double? = null, + val warnMinLimit: Double? = null, + val warnMaxLimit: Double? = null, + val historyBuffer: TimeSeriesRingBuffer = TimeSeriesRingBuffer(1000) +) { + + /** + * 현재 값 기준 임계치 경고 상태 평가 + */ + val alertState: TileAlertState + get() = evaluateAlertState(value, minLimit, maxLimit, warnMinLimit, warnMaxLimit) + + /** + * realLen 포맷팅된 문자열 반환 + */ + val formattedValue: String + get() = formatValue(value, realLen) + + companion object { + /** + * realLen 기준 수치 포맷팅 + * - 0: "8000" + * - 1: "85.2" + * - 2: "13.84" + * - >=3: "0.125" + */ + fun formatValue(value: Double, realLen: Int): String { + if (value.isNaN()) return "NaN" + if (value.isInfinite()) return if (value > 0) "+Inf" else "-Inf" + return when { + realLen <= 0 -> String.format(Locale.US, "%.0f", value) + realLen == 1 -> String.format(Locale.US, "%.1f", value) + realLen == 2 -> String.format(Locale.US, "%.2f", value) + else -> String.format(Locale.US, "%.3f", value) + } + } + + /** + * 임계치 상태 판별 + */ + fun evaluateAlertState( + value: Double, + minLimit: Double?, + maxLimit: Double?, + warnMin: Double? = null, + warnMax: Double? = null + ): TileAlertState { + if (minLimit != null && value < minLimit) return TileAlertState.DANGER + if (maxLimit != null && value > maxLimit) return TileAlertState.DANGER + + if (warnMin != null && value < warnMin) return TileAlertState.WARNING + if (warnMax != null && value > warnMax) return TileAlertState.WARNING + + return TileAlertState.NORMAL + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/formula/FormulaDefinition.kt b/Android/app/src/main/java/com/dct/app/domain/model/formula/FormulaDefinition.kt new file mode 100644 index 0000000..8032bc6 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/formula/FormulaDefinition.kt @@ -0,0 +1,16 @@ +package com.dct.app.domain.model.formula + +import java.util.UUID + +/** + * 사용자 정의 가상 수식 신호 (Formula Definition) + */ +data class FormulaDefinition( + val id: String = UUID.randomUUID().toString(), + val name: String, + val expression: String, + val unit: String = "", + val color: Long = 0xFF2196F3, + val isForSm: Boolean = false, + val description: String = "" +) \ No newline at end of file diff --git a/Android/app/src/main/java/com/dct/app/domain/model/maintenance/MaintenanceModels.kt b/Android/app/src/main/java/com/dct/app/domain/model/maintenance/MaintenanceModels.kt new file mode 100644 index 0000000..342b4d4 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/maintenance/MaintenanceModels.kt @@ -0,0 +1,51 @@ +package com.dct.app.domain.model.maintenance + +enum class FirmwareFormat { + BIN, + INTEL_HEX, + MOTOROLA_S19 +} + +/** 검증을 마치고 장비 전송용 연속 이미지로 정규화된 펌웨어. */ +data class FirmwareImage( + val fileName: String, + val format: FirmwareFormat, + val sourceLength: Long, + val startAddress: Long, + val bytes: ByteArray +) + +data class RemoteFirmwareFile( + val index: Int, + val name: String +) + +data class RemoteLogFile( + val index: Int, + val name: String, + val size: Long? = null +) + +data class TransferProgress( + val transferredBytes: Long = 0, + val totalBytes: Long = 0, + val isRunning: Boolean = false, + val message: String = "" +) { + val fraction: Float + get() = if (totalBytes <= 0) 0f else (transferredBytes.toDouble() / totalBytes).toFloat().coerceIn(0f, 1f) +} + +data class SeedKeyConfiguration( + val firmwareAddress: Long = 0, + val stMin: Int = 0, + val dependsOnRom: Boolean = false +) + +sealed interface SeedKeyState { + data object Idle : SeedKeyState + data object WaitingForSeed : SeedKeyState + data class Authenticated(val keyLength: Int) : SeedKeyState + data class Unavailable(val reason: String) : SeedKeyState + data class Failed(val reason: String) : SeedKeyState +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/mdb/MdbModels.kt b/Android/app/src/main/java/com/dct/app/domain/model/mdb/MdbModels.kt new file mode 100644 index 0000000..d1bc934 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/mdb/MdbModels.kt @@ -0,0 +1,277 @@ +package com.dct.app.domain.model.mdb + +import com.dct.app.domain.model.a2l.A2lDataType +import com.healthmarketscience.jackcess.DataType + +/** + * MDB 신호 및 파라미터 ATTR 데이터 타입 (0 ~ 5) + * C++ PC 진실의 원천(LibDB.cpp, MainFrm.cpp) 100% 호환 + */ +enum class MdbAttrType( + val code: Int, + val byteSize: Int, + val isSigned: Boolean, + val displayName: String +) { + ATTR_0_UINT8(0, 1, false, "1-Byte Unsigned Integer (0 ~ 255)"), + ATTR_1_INT8(1, 1, true, "1-Byte Signed Integer (-128 ~ 127)"), + ATTR_2_UINT16(2, 2, false, "2-Byte Unsigned Integer Little-Endian (0 ~ 65535)"), + ATTR_3_INT16(3, 2, true, "2-Byte Signed Integer Little-Endian (-32768 ~ 32767)"), + ATTR_4_UINT32(4, 4, false, "4-Byte Unsigned Integer Little-Endian (0 ~ 4294967295)"), + ATTR_5_INT32(5, 4, true, "4-Byte Signed Integer Little-Endian (-2147483648 ~ 2147483647)"); + + companion object { + fun fromCode(code: Int): MdbAttrType = when (code) { + 0 -> ATTR_0_UINT8 + 1 -> ATTR_1_INT8 + 2 -> ATTR_2_UINT16 + 3 -> ATTR_3_INT16 + 4 -> ATTR_4_UINT32 + 5 -> ATTR_5_INT32 + else -> ATTR_0_UINT8 + } + } +} + +/** Vector DBC의 단일 단계 멀티플렉스 역할 (`M` / `m`). */ +enum class CanMultiplexRole { + NONE, + MULTIPLEXOR, + MULTIPLEXED +} + +/** + * MDB VarLst 테이블의 개별 계측 신호 정의 + */ +data class SignalDefinition( + val reclid: Int, + val idx: Int, + val name: String, + val attr: MdbAttrType, + val cvsA: Double, + val cvsB: Double, + val unit: String, + val color: Long, + val min: Double, + val max: Double, + val description: String = "", + val cvsT: String = "", + val adl: Int = 0, + val itemSize: Int = attr.byteSize, + var arrayIndex: Int = 0, + // CAN DBC 확장 필드 (PC ReportDBCSignalFormView 100% 호환) + val messageName: String = "", + val messageId: Long = 0L, + val messageDlc: Int = 8, + val startBit: Int = 0, + val bitLength: Int = attr.byteSize * 8, + val realLen: Int = calculateRealLen(cvsA, min), + val isMotorola: Boolean = false, + val multiplexRole: CanMultiplexRole = CanMultiplexRole.NONE, + val multiplexValue: Long? = null, + /** A2L 원본 타입. FLOAT/64-bit 타입이 MDB 호환 attr로 축약되어도 XCP에는 원형을 전송한다. */ + val a2lDataType: A2lDataType? = null, + val measurementFormat: String = "%6.3f" +) { + companion object { + fun calculateRealLen(cvsA: Double, minVal: Double = 0.0): Int { + val fracA = kotlin.math.abs(cvsA - cvsA.toLong()) + if (fracA > 1e-9) { + val strA = String.format(java.util.Locale.US, "%.15f", fracA).trimEnd('0') + val dotA = strA.indexOf('.') + if (dotA >= 0) { + val len = strA.length - dotA - 1 + return len.coerceIn(1, 3) + } + } + val fracMin = kotlin.math.abs(minVal - minVal.toLong()) + if (fracMin > 1e-9) { + val strMin = String.format(java.util.Locale.US, "%.15f", fracMin).trimEnd('0') + val dotMin = strMin.indexOf('.') + if (dotMin >= 0) { + val len = strMin.length - dotMin - 1 + return len.coerceIn(1, 3) + } + } + return 0 + } + } + + /** + * 물리값 계산: PhysicalValue = (RawValue * cvsA) + cvsB + */ + fun calculatePhysicalValue(rawValue: Number): Double { + return (rawValue.toDouble() * cvsA) + cvsB + } + + /** + * 역변환: RawValue = (PhysicalValue - cvsB) / cvsA + */ + fun calculateRawValue(physicalValue: Double): Double { + if (cvsA == 0.0) return 0.0 + return (physicalValue - cvsB) / cvsA + } + + /** + * PC MFC CVarLstItemData::m_nRealLen 및 ReportMDBSignalFormView 100% 일치 표시 포맷팅 + */ + fun formatMeasurementValue(value: Double): String { + val safe = Regex("%[0-9]{0,2}\\.[0-9]{1,2}[fFeEgG]").matches(measurementFormat) + return String.format(java.util.Locale.US, if (safe) measurementFormat else "%.3f", value).trim() + } + + fun formatRawHex(rawValue: Long?): String { + if (rawValue == null) return "N/A" + val mask = (1L shl (attr.byteSize * 8)) - 1L + return "0x" + (rawValue and mask).toString(16).uppercase(java.util.Locale.ROOT).padStart(attr.byteSize * 2, '0') + } + + fun formatDisplayValue(value: Double?): String { + val v = value ?: 0.0 + return when (realLen) { + 0 -> String.format(java.util.Locale.US, "%.0f", kotlin.math.round(v)) + 1 -> String.format(java.util.Locale.US, "%.1f", kotlin.math.round(v * 10.0) / 10.0) + 2 -> String.format(java.util.Locale.US, "%.2f", kotlin.math.round(v * 100.0) / 100.0) + else -> String.format(java.util.Locale.US, "%.3f", kotlin.math.round(v * 1000.0) / 1000.0) + } + } +} + +/** + * MDB VarNif 테이블의 신호 그룹 메타데이터 + */ +data class SignalGroup( + val reclid: Int, + val strIndex: String, + val description: String, + val usage: Boolean, + var startIndex: Int = 0, + var itemCount: Int = 0, + val signals: List = emptyList() +) + +/** + * MDB ConLst 테이블의 캘리브레이션 상수 + */ +data class MdbConstant( + val idx: Int, + val name: String, + val data: Double, + val addr: Long, + val min: Double, + val max: Double, + val attr: MdbAttrType, + val description: String = "" +) + +/** + * 1D 보정 맵 타입 (Type A: PxDy vs Type B: PyDx) + */ +enum class CompensationMapType { + TYPE_A, // Tb1DmPxDyLst (X: Physical Value, Y: Raw Data) + TYPE_B // Tb1DmPyDxLst (X: Breakpoint, Y: Physical Value) +} + +/** + * 개별 1D 보정 맵 데이터 포인트 ([YNAME] 테이블의 개별 행) + */ +data class MapPoint( + val idx: Int, + val bkp: Int, + val data: Int, + val phy: Double +) + +/** + * 1D 보정 맵 (Tb1DmPxDyLst / Tb1DmPyDxLst + [YNAME] 동적 테이블 결합) + */ +data class CompensationMap( + val type: CompensationMapType, + val idx: Int, + val yName: String, + val yAddr: Long, + val yAttr: MdbAttrType, + val yMin: Double, + val yMax: Double, + val xName: String, + val xAddr: Long, + val xAttr: MdbAttrType, + val cvsA: Double, + val cvsB: Double, + val description: String = "", + val points: List = emptyList() +) { + val isTypeA: Boolean get() = type == CompensationMapType.TYPE_A + val isTypeB: Boolean get() = type == CompensationMapType.TYPE_B +} + +/** + * MDB 데이터베이스 전체 도메인 구조체 + */ +data class MdbDatabase( + val fileName: String = "", + val groups: List = emptyList(), + val constants: List = emptyList(), + val compensationMaps: List = emptyList(), + val rawTableNames: List = emptyList() +) { + val allSignals: List get() = groups.flatMap { it.signals } + fun findSignal(name: String): SignalDefinition? = allSignals.find { it.name.equals(name, ignoreCase = true) } + fun findGroup(reclid: Int): SignalGroup? = groups.find { it.reclid == reclid } + fun findConstant(name: String): MdbConstant? = constants.find { it.name.equals(name, ignoreCase = true) } + fun findCompensationMap(name: String): CompensationMap? = compensationMaps.find { it.yName.equals(name, ignoreCase = true) } +} + +/** + * MDB 테이블의 개별 컬럼 메타데이터 (Generic Table Explorer 호환) + */ +data class MdbColumn( + val name: String, + val type: DataType, + val typeName: String = type.name, + val length: Int = 0, + val isAutoNumber: Boolean = false +) + +/** + * MDB 테이블의 행 데이터 (Key: 컬럼명, Value: 셀 값) + */ +data class MdbRow( + val index: Int, + val values: Map +) { + operator fun get(columnName: String): Any? = values[columnName] +} + +/** + * MDB 특정 테이블의 전체 데이터 (컬럼 스키마 + 행 데이터) + */ +data class MdbTable( + val name: String, + val columns: List, + val rows: List +) { + val rowCount: Int get() = rows.size + val columnCount: Int get() = columns.size +} + +/** + * MDB 내 테이블 요약 정보 (탐색기 목록 표시용) + */ +data class MdbTableSummary( + val tableName: String, + val rowCount: Int, + val columnCount: Int +) + +/** + * 열린 MDB 데이터베이스 전체 메타데이터 + */ +data class MdbDatabaseInfo( + val fileName: String, + val filePath: String, + val fileSizeFormatted: String, + val fileFormat: String, + val tableCount: Int, + val tables: List +) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt new file mode 100644 index 0000000..7c8e6be --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt @@ -0,0 +1,124 @@ +package com.dct.app.domain.model.project + +import android.net.Uri + +/** + * UDS ISO 14229 표준 ECU 진단 식별자(DID) 구성 정보 + */ +data class DpcDidInfo( + val partName2: String = "", // DIDF100PN2 (최대 25자) + val ecuHwVersion: String = "", // DIDF191HW (최대 4자) + val partNumber: String = "", // DIDF187PN (최대 10자) + val productDate: String = "", // DIDF18BPD (최대 8자) + val supplierCode: String = "", // DIDF1A1SC (최대 4자) + val systemName: String = "", // DIDF197SN (최대 4자) + val supplierHwVersion: String = "", // DIDF193SHW (최대 4자) + val preSetting: Int = 49 +) + +data class DpcAskInfo( + val firmwareAddress: String = "", + val stMin: Int = 0, + val seedKeyPath: String = "", + val dependsOnRom: Boolean = false +) + +/** + * 센서 모듈(Sensor Module) 개별 채널 구성 (1개 모듈당 16채널) + */ +data class DpcSensorModuleChannel( + val channelIndex: Int, // 0..15 + val name: String, // 예: "01-01 ch" + val isVoltage: Boolean = false, // false = Temperature ('C), true = Voltage (V) + val unit: String = if (isVoltage) "V" else "'C", + val description: String = "", + val color: Long = 0xFF009A4CL +) + +/** + * 센서 모듈(SM_01 ~ SM_04) 개별 모듈 구성 + */ +data class DpcSensorModuleConfig( + val moduleIndex: Int, // 0..3 (SM 1..4) + val isUsed: Boolean = (moduleIndex == 0), + val channels: List = createDefaultSmChannels(moduleIndex) +) + +/** + * 기본 16채널 센서 모듈 채널 목록 생성 함수 + */ +fun createDefaultSmChannels(moduleIndex: Int): List { + val modNum = String.format(java.util.Locale.US, "%02d", moduleIndex + 1) + return (1..16).map { ch -> + val chNum = String.format(java.util.Locale.US, "%02d", ch) + DpcSensorModuleChannel( + channelIndex = ch - 1, + name = "$modNum-$chNum ch", + isVoltage = false, + unit = "'C", + description = "" + ) + } +} + +/** + * 기본 4개 센서 모듈(SM 1~4) 목록 생성 함수 + */ +fun createDefaultSensorModules(): List { + return (0..3).map { idx -> + DpcSensorModuleConfig( + moduleIndex = idx, + isUsed = (idx == 0), + channels = createDefaultSmChannels(idx) + ) + } +} + +/** + * DCT 프로젝트 설정 모델 (DPC / MDB / A2L / DBC 파일 경로 및 계측 옵션) + */ +data class DpcProject( + val vehicleCode: String = "", + val projectFileName: String = "", + val projectFileUri: Uri? = null, + val localProjectPath: String? = null, + val mdbFileName: String = "", + val mdbFileUri: Uri? = null, + val localMdbPath: String? = null, + val a2lFileName: String = "", + val a2lFileUri: Uri? = null, + val localA2lPath: String? = null, + val canDbcFileName: String = "", + val canDbcFileUri: Uri? = null, + val localCanDbcPath: String? = null, + val measuredFilesFolder: String = "", + val measuredFolderUri: Uri? = null, + val writeCsv: Boolean = true, // 기본 선택: CSV 활성화 + val writeMf4: Boolean = false, // 기본 선택: MF4 비활성화 + val canMode: String = "CAN(HiSpeed)", // "CAN(HiSpeed)" or "CAN(FD)" + val forOldModel: Boolean = false, + val smConfigured: Boolean = false, + val didConfigured: Boolean = false, + val didInfo: DpcDidInfo = DpcDidInfo(), + val askInfo: DpcAskInfo = DpcAskInfo(), + val sensorModules: List = createDefaultSensorModules(), + val baseFolder: String = "", + val copySavedFolder: String = "", + val maker: Int = 2, + val isaveLogfile: Boolean = true, + val autostartMeasuringRecording: Boolean = false, + val canSignalColors: Map = emptyMap(), + val canSignalDescriptions: Map = emptyMap(), + val mdbSignalColors: Map = emptyMap(), + val mdbSignalDescriptions: Map = emptyMap(), + val a2lSignalColors: Map = emptyMap(), + val a2lSignalDescriptions: Map = emptyMap(), + val mdbDatabase: com.dct.app.domain.model.mdb.MdbDatabase? = null, + val canSignals: List = emptyList(), + val a2lSignals: List = emptyList(), + val formulas: List = emptyList(), + val rawJsonRoot: org.json.JSONObject? = null, + val workspace: DpcWorkspaceState? = null, + val graphLinear: Boolean = true, + val parameterDescriptions: Map = emptyMap() +) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/project/DpcWorkspaceState.kt b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcWorkspaceState.kt new file mode 100644 index 0000000..55651f3 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/project/DpcWorkspaceState.kt @@ -0,0 +1,89 @@ +package com.dct.app.domain.model.project + +import com.dct.app.domain.model.chart.* + +/** File-only state: never contains telemetry buffers, connections or Compose objects. */ +data class DpcWorkspaceState( + val activePageId: String, + val pages: List +) + +data class DpcPageState( + val id: String, + val title: String, + val windows: List = emptyList(), + val activeWindowId: String? = null, + val scrollX: Float = 0f, + val scrollY: Float = 0f, + val signals: List = emptyList(), + val alphanumericalSignals: List = emptyList(), + val timeWindowSec: Float = 10f, + val timeWindowPreset: TimeWindowPreset = TimeWindowPreset.SEC_10, + val mdbSearchQuery: String = "", + val canSearchQuery: String = "", + val createdAt: Long = 0L +) + +data class DpcWindowState( + val id: String, + val type: String, + val title: String, + val x: Float = 20f, + val y: Float = 20f, + val width: Float = 360f, + val height: Float = 300f, + val zIndex: Float = 0f, + val isClosable: Boolean = true, + val isMinimized: Boolean = false, + val isMaximized: Boolean = false, + val createdAt: Long = 0L, + val timeWindowPreset: TimeWindowPreset = TimeWindowPreset.SEC_10, + val dashboardGridMode: String = "GRID_3X3", + val targetTableName: String = "", + val targetParameterName: String = "", + val autoScroll: Boolean = true, + val signals: List = emptyList(), + val targetSignals: List = emptyList(), + val isTileMode: Boolean = true, + val hiddenSignalIds: Set = emptySet(), + val highlightColors: Map = emptyMap(), + val contentState: Map = emptyMap(), + val chart: DpcChartState? = null +) + +data class DpcSignalBindingState( + // Existing keys retain their source namespace: sig_, can_, a2l_, formula_, sm_. + val signalId: String, + val signalName: String, + val axisPosition: AxisPosition, + val color: Long, + val unit: String = "", + val lineWidthDp: Float = 2f, + val dashStyle: Int = 0, + val isVisible: Boolean = true, + val interpolationMode: InterpolationMode = InterpolationMode.LINEAR +) { + fun toSeries() = ChartSignalSeries( + signalId, signalName, axisPosition, color, unit, lineWidthDp, + dashStyle, isVisible, interpolationMode, + // Page/window config holds metadata only. The renderer allocates the actual sample buffer. + buffer = com.dct.app.data.ringbuffer.TimeSeriesRingBuffer(1) + ) + + companion object { + fun fromSeries(s: ChartSignalSeries) = DpcSignalBindingState( + s.signalId, s.signalName, s.axisPosition, s.color, s.unit, + s.lineWidthDp, s.dashStyle, s.isVisible, s.interpolationMode + ) + } +} + +data class DpcChartState( + val axes: List = emptyList(), + val timeWindowSec: Double = 30.0, + val timeWindowPreset: TimeWindowPreset = TimeWindowPreset.SEC_30, + val isAutoTimeWindow: Boolean = false, + val isXAxisAuto: Boolean = true, + val isYAxisAuto: Boolean = true, + val legendPosition: Int = 0 +) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctCommand.kt b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctCommand.kt new file mode 100644 index 0000000..3eb21d6 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctCommand.kt @@ -0,0 +1,115 @@ +package com.dct.app.domain.model.protocol + +/** + * DCT 바이너리 프로토콜 커맨드 및 응답 식별자 정의. + * PC C++ 레퍼런스(refer/dct_sw_xcp_pc/DCT/common_def.h 및 MainFrm.cpp)를 기반으로 합니다. + */ +enum class DctCommand(val code: Byte, val description: String) { + // ---------------------------------------------------- + // Host -> Device Commands (DCT_CMD_MSG) + // ---------------------------------------------------- + CMD_ENABLE(0x00.toByte(), "Connect / Communication Enable Request"), + CMD_VERSION(0x01.toByte(), "Firmware Version Request"), + CMD_CUR_STATUS(0x02.toByte(), "Current Status Request"), + CMD_HWID(0x03.toByte(), "Hardware ID Request"), + CMD_SYNC_TIME(0x04.toByte(), "Time Synchronization"), + CMD_POSSIBLE_TO_CONNECT(0x05.toByte(), "Check Connection Possibility"), + CMD_CARTYPE(0x06.toByte(), "Car Type and Maker Information"), + CMD_CARTYPE_CONTROL(0x07.toByte(), "Car Type Control"), + CMD_DB(0x08.toByte(), "DB Synchronization"), + CMD_DCT_ENV(0x09.toByte(), "Environment Settings (AutoSave, Beep, Haptic)"), + CMD_DISABLE(0x0F.toByte(), "Disconnect / Communication Disable Request"), + + CMD_MDB_INFO(0x10.toByte(), "MDB Signal Definition List"), + CMD_CANDBC_INFO(0x11.toByte(), "CAN DBC Message/Signal ID List"), + CMD_SM_INFO(0x12.toByte(), "Sensor Module Info Request"), + CMD_MDB_SELECTED_ID(0x13.toByte(), "Selected MDB Monitoring IDs"), + CMD_COMPENSATION_TYPE_DATA(0x14.toByte(), "Compensation Table (Type A/B) Read/Write"), + CMD_DID_INFO(0x15.toByte(), "Write DID Info"), + CMD_REQ_DID_INFO(0x16.toByte(), "Request Specific DID Info"), + CMD_SETUP_DID_INFO(0x17.toByte(), "Setup PreSetup DID Info"), + CMD_REQ_ALL_DID_INFO(0x18.toByte(), "Request All DID Info"), + + CMD_START_TRANSMIT_DATA(0x20.toByte(), "Start Measurement / Data Stream"), + CMD_STOP_TRANSMIT_DATA(0x21.toByte(), "Stop Measurement / Data Stream"), + CMD_CAN_FRAME_VIA_DCT(0x22.toByte(), "CAN Frame Transmission via DCT"), + CMD_CAN_FRAME_ACK(0x23.toByte(), "CAN / MDB Stream Host ACK"), + CMD_SM_DATA_ACK(0x24.toByte(), "Sensor Module Stream Host ACK"), + CMD_SAVED_LOGFILE_INFO(0x25.toByte(), "Saved Logfile Info Request"), + CMD_SAVED_LOGFILE_COPY(0x26.toByte(), "Saved Logfile Download Request"), + CMD_MDB_CONSTANT_DATA(0x27.toByte(), "MDB Constant Write (Single Address)"), + CMD_MDB_REQ_CONSTANT_DATA(0x28.toByte(), "MDB Constant Read (Single Address)"), + + CMD_FW_INFO(0x31.toByte(), "Firmware Info / OTA"), + CMD_FW_KEY_VALUE(0x32.toByte(), "Firmware Seed-Key Response"), + CMD_HASH_INFO(0x33.toByte(), "Firmware Hash Information"), + CMD_FW_ASK_ADDRESS(0x34.toByte(), "Firmware ASK Address"), + + CMD_XCP_USE(0x40.toByte(), "XCP Use Mode Configuration"), + CMD_XCP_IFDATAS(0x41.toByte(), "XCP CAN IFData Configuration"), + CMD_XCP_MEASUREMENTS(0x42.toByte(), "XCP Measurement Address Configuration"), + CMD_XCP_MEASUREMENT_DATA(0x50.toByte(), "XCP Measurement Data Host ACK"), + CMD_XCP_CHARACTERISTIC_DATA(0x51.toByte(), "XCP Characteristic Write"), + CMD_XCP_REQ_CHARACTERISTIC_DATA(0x52.toByte(), "XCP Characteristic Read"), + + // ---------------------------------------------------- + // Device -> Host Responses (DCT_RES_COMMAND) + // ---------------------------------------------------- + RES_ENABLE(0x80.toByte(), "Communication Enable ACK"), + RES_VERSION(0x81.toByte(), "Firmware Version Response"), + RES_CUR_STATUS(0x82.toByte(), "Current Status Response"), + RES_HWID(0x83.toByte(), "Hardware ID Response"), + RES_SYNC_TIME(0x84.toByte(), "Time Synchronization ACK"), + RES_POSSIBLE_TO_CONNECT(0x85.toByte(), "Connection Port Response"), + RES_CARTYPE(0x86.toByte(), "Car Type Configuration ACK"), + RES_CARTYPE_CONTROL(0x87.toByte(), "Car Type Control Response"), + RES_DB(0x88.toByte(), "DB Processing Response"), + RES_DCT_ENV(0x89.toByte(), "Environment Settings Response"), + + RES_MDB_INFO(0x90.toByte(), "MDB Info ACK"), + RES_CANDBC_INFO(0x91.toByte(), "CAN DBC Info ACK"), + RES_SM_INFO(0x92.toByte(), "Sensor Module Info Response"), + RES_MDB_SELECTED_ID(0x93.toByte(), "Selected MDB Monitoring IDs ACK"), + RES_COMPENSATION_TYPE_DATA(0x94.toByte(), "Compensation Table ACK / Read Data"), + RES_DID_INFO(0x95.toByte(), "DID Info Write ACK"), + RES_REQ_DID_INFO(0x96.toByte(), "DID Info Single Response"), + RES_SETUP_DID_INFO(0x97.toByte(), "PreSetup DID Info ACK"), + RES_REQ_ALL_DID_INFO(0x98.toByte(), "All DID Info Batch Response"), + + RES_START_TRANSMIT_DATA(0xA0.toByte(), "Start Measurement ACK"), + RES_STOP_TRANSMIT_DATA(0xA1.toByte(), "Stop Measurement ACK"), + RES_CAN_FRAME_VIA_DCT(0xA2.toByte(), "CAN Frame Transmission Result"), + SEND_CAN_FRAME(0xA3.toByte(), "Real-time CAN / MDB Data Stream"), + SEND_SM_DATA(0xA4.toByte(), "Real-time Sensor Module Data Stream"), + RES_SAVED_LOGFILE_INFO(0xA5.toByte(), "Saved Logfile Info Response"), + RES_SAVED_LOGFILE_COPY(0xA6.toByte(), "Saved Logfile Chunk Transfer"), + RES_MDB_CONSTANT_DATA(0xA7.toByte(), "MDB Constant Write ACK"), + RES_MDB_REQ_CONSTANT_DATA(0xA8.toByte(), "MDB Constant Read Response"), + + RES_FW_INFO(0xB1.toByte(), "Firmware Info Response"), + SEND_FW_SEEDVALUE(0xB2.toByte(), "Seed-Key Seed Value Transfer"), + SEND_HASH_INFO(0xB3.toByte(), "Hash Info Request"), + RES_FW_ASK_ADDRESS(0xB4.toByte(), "Firmware ASK Address Response"), + + RES_XCP_USE(0xC0.toByte(), "XCP Use Mode ACK"), + RES_XCP_IFDATAS(0xC1.toByte(), "XCP IFData ACK"), + RES_XCP_MEASUREMENTS(0xC2.toByte(), "XCP Measurements ACK"), + SEND_XCP_MEASUREMENT_DATA(0xD0.toByte(), "Real-time XCP Measurement Stream"), + RES_XCP_CHARACTERISTIC_DATA(0xD1.toByte(), "XCP Characteristic Write ACK"), + RES_XCP_REQ_CHARACTERISTIC_DATA(0xD2.toByte(), "XCP Characteristic Read Response"); + + companion object { + private val codeMap = entries.associateBy { it.code } + + fun fromByte(code: Byte): DctCommand? = codeMap[code] + + fun isResponse(code: Byte): Boolean { + val unsigned = code.toInt() and 0xFF + return unsigned >= 0x80 + } + + fun toHexString(code: Byte): String { + return String.format("0x%02X", code.toInt() and 0xFF) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt new file mode 100644 index 0000000..4060f51 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt @@ -0,0 +1,21 @@ +package com.dct.app.domain.model.protocol + +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.project.DpcSensorModuleConfig + +/** 실제 DCT 장비에 측정 시작 전 적용할 프로젝트 설정의 불변 스냅샷. */ +data class DctMeasurementConfiguration( + val vehicleCode: String = "", + val maker: Int = 2, + val mdbSignals: List = emptyList(), + val selectedMdbSignals: List = mdbSignals, + val canMessages: List = emptyList(), + val sensorModules: List = emptyList(), + val isHighSpeedCan: Boolean = true, + val isOldModel: Boolean = false +) + +data class DctCanMessageConfiguration( + val canId: Long, + val dlc: Int +) diff --git a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctPacket.kt b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctPacket.kt new file mode 100644 index 0000000..64d8c1e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctPacket.kt @@ -0,0 +1,71 @@ +package com.dct.app.domain.model.protocol + +/** + * DCT 통신 프로토콜 기본 패킷 데이터 모델. + * + * @property command 커맨드 또는 응답 바이트 (1 Byte) + * @property payload 패킷 페이로드 바이트 배열 (0..N 바이트) + * @property sequence 패킷 순서 식별자 (0..65535) + */ +data class DctPacket( + val command: Byte, + val payload: ByteArray = ByteArray(0), + val sequence: Int = 0 +) { + val commandEnum: DctCommand? + get() = DctCommand.fromByte(command) + + val commandHex: String + get() = String.format("0x%02X", command.toInt() and 0xFF) + + val payloadLength: Int + get() = payload.size + + val payloadHex: String + get() = payload.joinToString(" ") { String.format("%02X", it.toInt() and 0xFF) } + + fun isResponse(): Boolean = DctCommand.isResponse(command) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DctPacket + + if (command != other.command) return false + if (!payload.contentEquals(other.payload)) return false + if (sequence != other.sequence) return false + + return true + } + + override fun hashCode(): Int { + var result = command.toInt() + result = 31 * result + payload.contentHashCode() + result = 31 * result + sequence + return result + } + + override fun toString(): String { + val cmdName = commandEnum?.name ?: commandHex + return "DctPacket(cmd=$cmdName ($commandHex), seq=$sequence, len=${payload.size}, payload=[$payloadHex])" + } + + companion object { + fun create(command: DctCommand, payload: ByteArray = ByteArray(0), sequence: Int = 0): DctPacket { + return DctPacket( + command = command.code, + payload = payload, + sequence = sequence + ) + } + + fun create(commandByte: Byte, payload: ByteArray = ByteArray(0), sequence: Int = 0): DctPacket { + return DctPacket( + command = commandByte, + payload = payload, + sequence = sequence + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/repository/MdbRepository.kt b/Android/app/src/main/java/com/dct/app/domain/repository/MdbRepository.kt new file mode 100644 index 0000000..729ffb2 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/repository/MdbRepository.kt @@ -0,0 +1,155 @@ +package com.dct.app.domain.repository + +import android.content.Context +import android.net.Uri +import com.dct.app.core.common.DctResult +import com.dct.app.domain.model.mdb.* +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +/** + * Pure Java MS Access (MDB / ACCDB) 데이터베이스 연동 레포지토리 인터페이스 + * + * M3 요구사항: + * 1. 도메인 단위 로드/저장/수정 (MdbDatabase, MdbConstant, CompensationMap) + * 2. 원자적 SAF 플러시 & 로컬 캐시 안전성 + * 3. 범용 테이블 데이터 탐색 및 CRUD 호환 + */ +interface MdbRepository { + + // ========================================================================= + // 1. M3 고수준 도메인 MDB 엔진 API + // ========================================================================= + + /** + * InputStream(SAF 또는 파일)으로부터 MDB를 로드하여 MdbDatabase 도메인 객체로 반환 + */ + suspend fun loadMdb(inputStream: InputStream): DctResult + + /** + * File 객체로부터 MDB를 로드하여 MdbDatabase 도메인 객체로 반환 + */ + suspend fun loadMdb(file: File): DctResult + + /** + * 현재 로드/수정된 MDB 데이터베이스를 OutputStream으로 내보내기/저장 + */ + suspend fun saveMdb(database: MdbDatabase, outputStream: OutputStream): DctResult + + /** + * ConLst 테이블의 특정 상수(name) 값을 수정 + */ + suspend fun updateConstant(name: String, value: Double): DctResult + + /** + * ConLst 테이블의 특정 상수(name) 값 및 설명(description)을 수정 + */ + suspend fun updateConstant(name: String, value: Double, description: String?): DctResult + + /** + * 1D 보정 맵([YNAME])의 포인트 목록을 일괄 수정 + */ + suspend fun updateCompensationMap(name: String, points: List): DctResult + + /** + * 1D 보정 맵(Tb1DmPxDyLst / Tb1DmPyDxLst)의 메타데이터(Min/Max/CVS/설명) 수정 + */ + suspend fun updateCompensationMetadata( + isTypeA: Boolean, + yName: String, + min: Double, + max: Double, + cvsA: Double, + cvsB: Double, + description: String? = null + ): DctResult + + /** + * MDB VarLst / Group_Sig 테이블의 특정 신호(signalName) Description을 바이너리로 직접 수정 및 플러시 (PC LibDB.cpp 100% 호환) + */ + suspend fun updateSignalDescription( + context: Context? = null, + signalName: String, + description: String + ): DctResult + + /** + * 표준 테스트/시뮬레이션용 MDB 데이터베이스 바이너리를 OutputStream으로 생성 + */ + suspend fun createStandardSampleDatabase(outputStream: OutputStream): DctResult + + /** + * 표준 테스트/시뮬레이션용 MDB 데이터베이스를 지정된 파일에 생성하고 MdbDatabase 도메인 객체 반환 + */ + suspend fun createStandardSampleDatabase(targetFile: File): DctResult + + /** + * 현재 인메모리에 로드된 MdbDatabase 도메인 객체 반환 + */ + fun getCurrentDatabase(): MdbDatabase? + + // ========================================================================= + // 2. 범용 테이블 데이터 탐색 및 CRUD 호환 API + // ========================================================================= + + /** + * 외부 SAF Uri로부터 MDB 파일 열기 + */ + suspend fun openDatabase(context: Context, uri: Uri): DctResult + + /** + * File 객체로부터 MDB 열기 + */ + suspend fun openDatabase(file: File): DctResult + + /** + * 테스트/시뮬레이션용 표준 DCT MDB 데이터베이스 생성 + */ + suspend fun createSampleDatabase(targetFile: File): DctResult + + /** + * 현재 열린 DB의 사용자 정의 테이블 목록 반환 (MSys* 시스템 테이블 제외) + */ + fun getTableNames(): List + + /** + * 특정 테이블의 모든 컬럼 및 행 데이터 조회 (Read) + */ + suspend fun getTableData(tableName: String): DctResult + + /** + * 특정 테이블에 새 행 추가 (Insert / Create) + */ + suspend fun addRow(tableName: String, values: Map): DctResult + + /** + * 특정 테이블의 특정 행 데이터 수정 (Update / Edit) + */ + suspend fun updateRow(tableName: String, rowIndex: Int, values: Map): DctResult + + /** + * 특정 테이블의 특정 행 삭제 (Delete) + */ + suspend fun deleteRow(tableName: String, rowIndex: Int): DctResult + + /** + * 현재 수정된 MDB 파일을 대상 Uri로 내보내기 (Save As / Export) + */ + suspend fun saveToUri(context: Context, targetUri: Uri): DctResult + + /** + * 원본 SAF 소스로 덮어쓰기 저장 (Quick Save) + */ + suspend fun saveToOriginal(context: Context): DctResult + + /** + * 현재 열린 데이터베이스 정보 반환 + */ + fun getCurrentDbInfo(): MdbDatabaseInfo? + + /** + * 데이터베이스 연결 해제 및 리소스 정리 + */ + fun close() +} diff --git a/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt b/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt new file mode 100644 index 0000000..e03c41f --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt @@ -0,0 +1,104 @@ +package com.dct.app.domain.repository + +import com.dct.app.core.common.DctResult +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.model.protocol.DctMeasurementConfiguration +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * DCT 바이너리 패킷 통신 및 실시간 데이터 스트림 관리 레포지토리 인터페이스. + */ +interface PacketRepository { + /** + * 실제 DCT 장비 연결 상태. + */ + val isConnected: StateFlow + + /** 실제 장비 연결 및 계측 생명주기 상태. */ + val connectionState: StateFlow + + /** + * 현재 실시간 계측 데이터 스트리밍 진행 여부. + */ + val isStreaming: StateFlow + + /** + * 수신된 바이너리 패킷 실시간 스트림. + */ + val packetStream: SharedFlow + + /** + * 이미 연결된 Wi-Fi TCP 전송 계층에서 DCT 핸드셰이크를 수행합니다. + */ + suspend fun connect(target: String = ""): DctResult + + /** + * 연결을 종료합니다. + */ + suspend fun disconnect(): DctResult + + /** + * 커맨드 패킷을 송신하고 응답 패킷을 수신합니다. + */ + suspend fun sendCommand(command: DctPacket): DctResult + + /** 측정 시작 전에 PC와 동일한 순서로 장비 계측 설정을 적용합니다. */ + suspend fun configureMeasurement(configuration: DctMeasurementConfiguration): DctResult + + /** + * 실시간 데이터 측정을 시작합니다. + * + * @param frequencyHz 측정 주기 (20 ~ 50Hz) + */ + suspend fun startStreaming(frequencyHz: Int = 30): DctResult + + /** + * 실시간 데이터 측정을 정지합니다. + */ + suspend fun stopStreaming(): DctResult + + /** + * MDB Constant 단일 주소 데이터를 조회합니다 (0x28). + */ + suspend fun readConstant(address: Long, size: Int): DctResult + + /** + * MDB Constant 단일 주소에 데이터를 씁니다 (0x27). + */ + suspend fun writeConstant(address: Long, data: ByteArray): DctResult + + /** + * XCP 프로토콜 활성화 핸드셰이크 (0x40). + */ + suspend fun sendXcpUse(mode: Int = 0x02): DctResult + + /** + * XCP CAN IFData (Master/Slave ID, 엔디안) 설정 (0x41). + */ + suspend fun sendXcpIfDatas(ifData: com.dct.app.data.parser.A2lParser.XcpIfData): DctResult + + /** + * XCP DAQ 계측 대상 신호 목록 등록 (0x42). + */ + suspend fun sendXcpMeasurements(measurements: List): DctResult + + /** + * XCP Characteristic 파라미터 온라인 Write (0x51). + */ + suspend fun writeXcpCharacteristic(address: Long, dataType: com.dct.app.domain.model.a2l.A2lDataType, value: Double): DctResult + + /** + * XCP Characteristic 파라미터 온라인 Read (0x52). + */ + suspend fun readXcpCharacteristic(address: Long, dataType: com.dct.app.domain.model.a2l.A2lDataType): DctResult +} + +enum class DeviceConnectionState { + DISCONNECTED, + CONNECTING, + HANDSHAKING, + READY, + MEASURING, + ERROR +} diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/CreateStandardSampleMdbUseCase.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/CreateStandardSampleMdbUseCase.kt new file mode 100644 index 0000000..3b72530 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/CreateStandardSampleMdbUseCase.kt @@ -0,0 +1,22 @@ +package com.dct.app.domain.usecase + +import com.dct.app.core.common.DctResult +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.repository.MdbRepository +import java.io.File +import java.io.OutputStream + +/** + * 표준 DCT 테스트/시뮬레이션 MDB 생성 유스케이스 + */ +class CreateStandardSampleMdbUseCase( + private val repository: MdbRepository +) { + suspend operator fun invoke(targetFile: File): DctResult { + return repository.createStandardSampleDatabase(targetFile) + } + + suspend operator fun invoke(outputStream: OutputStream): DctResult { + return repository.createStandardSampleDatabase(outputStream) + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/GetMdbDatabaseUseCase.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/GetMdbDatabaseUseCase.kt new file mode 100644 index 0000000..983d2ce --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/GetMdbDatabaseUseCase.kt @@ -0,0 +1,26 @@ +package com.dct.app.domain.usecase + +import com.dct.app.core.common.DctResult +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.repository.MdbRepository +import java.io.File +import java.io.InputStream + +/** + * MDB 데이터베이스 로드 및 인메모리 도메인 데이터 조회 유스케이스 + */ +class GetMdbDatabaseUseCase( + private val repository: MdbRepository +) { + suspend operator fun invoke(file: File): DctResult { + return repository.loadMdb(file) + } + + suspend operator fun invoke(inputStream: InputStream): DctResult { + return repository.loadMdb(inputStream) + } + + fun getCurrentDatabase(): MdbDatabase? { + return repository.getCurrentDatabase() + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/SaveMdbUseCase.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/SaveMdbUseCase.kt new file mode 100644 index 0000000..14ad436 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/SaveMdbUseCase.kt @@ -0,0 +1,27 @@ +package com.dct.app.domain.usecase + +import android.content.Context +import android.net.Uri +import com.dct.app.core.common.DctResult +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.repository.MdbRepository +import java.io.OutputStream + +/** + * MDB 데이터베이스 파일 저장 및 SAF 원자적 내보내기 유스케이스 + */ +class SaveMdbUseCase( + private val repository: MdbRepository +) { + suspend operator fun invoke(database: MdbDatabase, outputStream: OutputStream): DctResult { + return repository.saveMdb(database, outputStream) + } + + suspend fun saveToUri(context: Context, targetUri: Uri): DctResult { + return repository.saveToUri(context, targetUri) + } + + suspend fun saveToOriginal(context: Context): DctResult { + return repository.saveToOriginal(context) + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/UpdateCompensationMapUseCase.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/UpdateCompensationMapUseCase.kt new file mode 100644 index 0000000..651de5a --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/UpdateCompensationMapUseCase.kt @@ -0,0 +1,36 @@ +package com.dct.app.domain.usecase + +import com.dct.app.core.common.DctResult +import com.dct.app.domain.model.mdb.MapPoint +import com.dct.app.domain.repository.MdbRepository + +/** + * 1D 보정 맵 포인트 및 메타데이터 수정 유스케이스 + */ +class UpdateCompensationMapUseCase( + private val repository: MdbRepository +) { + suspend operator fun invoke(name: String, points: List): DctResult { + return repository.updateCompensationMap(name, points) + } + + suspend fun updateMetadata( + isTypeA: Boolean, + yName: String, + min: Double, + max: Double, + cvsA: Double, + cvsB: Double, + description: String? = null + ): DctResult { + return repository.updateCompensationMetadata( + isTypeA = isTypeA, + yName = yName, + min = min, + max = max, + cvsA = cvsA, + cvsB = cvsB, + description = description + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/UpdateConstantUseCase.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/UpdateConstantUseCase.kt new file mode 100644 index 0000000..50340b3 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/UpdateConstantUseCase.kt @@ -0,0 +1,15 @@ +package com.dct.app.domain.usecase + +import com.dct.app.core.common.DctResult +import com.dct.app.domain.repository.MdbRepository + +/** + * MDB ConLst 캘리브레이션 상수 값 및 설명 수정 유스케이스 + */ +class UpdateConstantUseCase( + private val repository: MdbRepository +) { + suspend operator fun invoke(name: String, value: Double, description: String? = null): DctResult { + return repository.updateConstant(name, value, description) + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt new file mode 100644 index 0000000..545c64b --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt @@ -0,0 +1,759 @@ +package com.dct.app.domain.usecase.chart + +import com.dct.app.core.common.DispatcherProvider +import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.ChartAxisConfig +import com.dct.app.domain.model.chart.ChartRenderSnapshot +import com.dct.app.domain.model.chart.ChartSignalSeries +import com.dct.app.domain.model.chart.DownsamplingAlgorithms +import com.dct.app.domain.model.chart.InterpolationMode +import com.dct.app.domain.model.chart.SeriesRenderData +import com.dct.app.domain.model.chart.TimeWindowPreset +import com.dct.app.domain.model.chart.TimeAxisViewport +import com.dct.app.domain.model.chart.isValidChartAxisRange +import com.dct.app.domain.model.chart.interpolateChartValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 고성능 실시간 배치 렌더러 및 차트 컨트롤러 구현체 (PC 동등 60fps 부드러운 스트리밍) + */ +class ChartBatchRenderer( + private val dispatcherProvider: DispatcherProvider, + val throttleIntervalMs: Long = 16L +) : MultiAxisChartController { + + private val _seriesMap = ConcurrentHashMap() + private val seriesOrder = java.util.concurrent.CopyOnWriteArrayList() + private val _axesMap = ConcurrentHashMap() + + private val _seriesListFlow = MutableStateFlow>(emptyList()) + override val seriesListFlow: StateFlow> = _seriesListFlow.asStateFlow() + + private val defaultPrimaryAxis = ChartAxisConfig( + position = AxisPosition.LEFT_0, + minVal = -50.0, + maxVal = 100.0, + isAuto = true, + color = 0xFF424242L, + unit = "", + label = "LEFT_0", + tickCount = 5, + offsetDp = 0f, + isVisible = true + ) + + private val _renderSnapshotFlow = MutableStateFlow( + ChartRenderSnapshot( + timeMinSec = System.currentTimeMillis() / 1000.0, + timeMaxSec = System.currentTimeMillis() / 1000.0 + TimeWindowPreset.MIN_1.resetSpanSec, + seriesList = emptyList(), + axesConfig = mapOf(AxisPosition.LEFT_0 to defaultPrimaryAxis), + isPaused = false, + legendPosition = 0, + isXAxisAuto = true, + xAxisPreset = TimeWindowPreset.MIN_1, + isYAxisAuto = true + ) + ) + override val renderSnapshotFlow: StateFlow = _renderSnapshotFlow.asStateFlow() + + private val isDirty = AtomicBoolean(true) + @Volatile + private var isPaused = true + + @Volatile + private var timeWindowSec = TimeWindowPreset.MIN_1.resetSpanSec + + @Volatile + private var isAutoTimeWindow = false + + @Volatile + private var xAxisPreset = TimeWindowPreset.MIN_1 + + @Volatile + private var isXAxisAuto = true + + @Volatile + private var legendPosition = 0 // 0: Right, 1: Top, 2: Bottom + + // Null keeps the standalone controller's per-series API backwards compatible. + // The workspace sets a common mode, which also applies to future bindings/restores. + @Volatile + private var graphInterpolationMode: InterpolationMode? = null + + @Volatile + private var panOffsetSec: Double = 0.0 + + @Volatile + private var crosshairTimeSec: Double? = null + + private data class CursorSamples(val buffer: TimeSeriesRingBuffer, val points: List>) + private var cursorSamples: Map = emptyMap() + + @Volatile + var screenWidthPx: Int = 1200 // 렌더링 기준 가시 픽셀 폭 + + private var renderJob: Job? = null + private val timeViewport = TimeAxisViewport() + private data class NavigationHome(val span: Double, val preset: TimeWindowPreset, val auto: Boolean) + private var navigationHome: NavigationHome? = null + + private fun rememberNavigationHome() { + if (navigationHome == null) navigationHome = NavigationHome(timeWindowSec, xAxisPreset, isAutoTimeWindow) + } + + private fun allBoundAxesAuto(): Boolean { + val positions = _seriesMap.values.map { it.axisPosition }.toSet().ifEmpty { setOf(AxisPosition.LEFT_0) } + return positions.all { _axesMap[it]?.isAuto != false } + } + + private fun latestTimestamp(): Double? = + _seriesMap.values.mapNotNull { it.buffer.getLatestTimestamp() }.maxOrNull() + + init { + for (pos in AxisPosition.entries) { + val defaultColor = if (pos.isLeft) 0xFF1976D2L else 0xFF388E3CL + _axesMap[pos] = ChartAxisConfig( + position = pos, + minVal = -50.0, + maxVal = 100.0, + isAuto = true, + color = defaultColor, + unit = "", + label = pos.displayName, + tickCount = 5, + offsetDp = pos.index * 24f, + isVisible = (pos == AxisPosition.LEFT_0) + ) + } + } + + fun pause() { + isPaused = true + markDirty() + } + + fun resume() { + isPaused = false + markDirty() + } + + fun start(scope: CoroutineScope): Job { + renderJob?.cancel() + val job = scope.launch(dispatcherProvider.default) { + while (isActive) { + val startTime = System.currentTimeMillis() + if (isDirty.compareAndSet(true, false) || !isPaused) { + synchronized(this@ChartBatchRenderer) { + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + } + } + + val elapsed = System.currentTimeMillis() - startTime + val waitTime = (throttleIntervalMs - elapsed).coerceAtLeast(1L) + delay(waitTime) + } + } + renderJob = job + return job + } + + fun stop() { + pause() + renderJob?.cancel() + renderJob = null + } + + /** Reads controller state directly; the last rendered frame may be one frame behind. */ + @Synchronized + fun exportWorkspaceState() = com.dct.app.domain.model.project.DpcChartState( + axes = _axesMap.values.sortedBy { it.position.ordinal }, + timeWindowSec = timeWindowSec, timeWindowPreset = xAxisPreset, + isAutoTimeWindow = isAutoTimeWindow, isXAxisAuto = isXAxisAuto, + isYAxisAuto = allBoundAxesAuto(), legendPosition = legendPosition + ) + + @Synchronized + fun restoreWorkspaceState( + signals: List, + state: com.dct.app.domain.model.project.DpcChartState? + ) { + navigationHome = null + _seriesMap.clear() + seriesOrder.clear() + signals.forEach { s -> + // A fresh buffer is deliberate: project files contain layout, not a recording. + bindSignal(s.signalId, s.signalName, s.axisPosition, s.color, s.unit, s.interpolationMode, s.lineWidthDp) + _seriesMap[s.signalId] = _seriesMap.getValue(s.signalId).copy(dashStyle = s.dashStyle, isVisible = s.isVisible) + } + state?.let { + it.axes.forEach { axis -> _axesMap[axis.position] = axis } + // Older Android files stored the tick value as the whole window duration. + timeWindowSec = if (it.isXAxisAuto && !it.isAutoTimeWindow && + it.timeWindowPreset.tickIntervalSec == it.timeWindowSec) it.timeWindowPreset.displaySpanSec + else it.timeWindowSec + xAxisPreset = it.timeWindowPreset + isAutoTimeWindow = it.isAutoTimeWindow + isXAxisAuto = it.isXAxisAuto + if (it.axes.isEmpty()) _axesMap.replaceAll { _, axis -> axis.copy(isAuto = it.isYAxisAuto) } + legendPosition = it.legendPosition + } + crosshairTimeSec = null + cursorSamples = emptyMap() + panOffsetSec = 0.0 + timeViewport.reset() + updateSeriesList() + markDirty() + } + + fun markDirty() { + isDirty.set(true) + } + + override fun bindSignal(signalId: String, axisIndex: Int, isLeft: Boolean, color: Int) { + val position = AxisPosition.from(isLeft, axisIndex) + bindSignal( + signalId = signalId, + signalName = signalId, + axisPosition = position, + color = color.toLong() and 0xFFFFFFFFL + ) + } + + override fun bindSignal( + signalId: String, + signalName: String, + axisPosition: AxisPosition, + color: Long, + unit: String, + interpolationMode: InterpolationMode, + lineWidthDp: Float, + minVal: Double?, + maxVal: Double? + ) { + val buffer = TimeSeriesRingBuffer() + val series = ChartSignalSeries( + signalId = signalId, + signalName = signalName, + axisPosition = axisPosition, + color = color, + unit = unit, + lineWidthDp = lineWidthDp, + dashStyle = 0, + interpolationMode = graphInterpolationMode ?: interpolationMode, + isVisible = true, + buffer = buffer + ) + _seriesMap[signalId] = series + seriesOrder.addIfAbsent(signalId) + + _axesMap[axisPosition]?.let { + val finalMin = if (minVal != null && maxVal != null && maxVal > minVal) minVal else it.minVal + val finalMax = if (minVal != null && maxVal != null && maxVal > minVal) maxVal else it.maxVal + _axesMap[axisPosition] = it.copy( + isVisible = true, + color = color, + unit = unit, + label = signalName, + minVal = finalMin, + maxVal = finalMax + ) + } + + updateSeriesList() + markDirty() + } + + override fun unbindSignal(signalId: String) { + val removed = _seriesMap.remove(signalId) + seriesOrder.remove(signalId) + if (removed != null) { + val remaining = _seriesMap.values.find { it.axisPosition == removed.axisPosition } + if (remaining != null) { + // 해당 축에 다른 신호가 남아있다면 그 신호의 고유 색상으로 축 색상 동기화 + _axesMap[removed.axisPosition]?.let { + _axesMap[removed.axisPosition] = it.copy(color = remaining.color, unit = remaining.unit) + } + } else if (removed.axisPosition != AxisPosition.LEFT_0) { + _axesMap[removed.axisPosition]?.let { + _axesMap[removed.axisPosition] = it.copy(isVisible = false) + } + } else { + // LEFT_0 축에 더 이상 신호가 없으면 기본 회색으로 복원 + _axesMap[AxisPosition.LEFT_0]?.let { + _axesMap[AxisPosition.LEFT_0] = it.copy(color = 0xFF424242L) + } + } + } + updateSeriesList() + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun addSample(signalId: String, timestampMs: Long, rawValue: Double) { + addSampleSec(signalId, timestampMs.toDouble() / 1000.0, rawValue) + } + + override fun addSampleSec(signalId: String, timestampSec: Double, rawValue: Double) { + if (!timestampSec.isFinite()) return + val series = _seriesMap[signalId] ?: return + series.buffer.addSample(timestampSec, rawValue) + markDirty() + } + + override fun setTimeWindow(durationMs: Long) { + setTimeWindow(durationMs.toDouble() / 1000.0) + } + + @Synchronized + fun setTimeWindow(durationSec: Double) { + if (!durationSec.isFinite()) return + navigationHome = null + if (durationSec <= 0.0) { + isAutoTimeWindow = true + xAxisPreset = TimeWindowPreset.AUTO + } else { + isAutoTimeWindow = false + timeWindowSec = durationSec + xAxisPreset = TimeWindowPreset.MANUAL // Explicit duration is not a fixed tick interval. + } + isXAxisAuto = true + panOffsetSec = 0.0 + timeViewport.reset() + val latest = latestTimestamp() + if (!isAutoTimeWindow && latest != null) { + timeViewport.pin(latest - timeWindowSec, latest, latest) + } + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + @Synchronized + override fun setTimeWindow(preset: TimeWindowPreset) { + if (preset == TimeWindowPreset.MANUAL) { + setXAxisManualMode() + return + } + navigationHome = null + xAxisPreset = preset + isXAxisAuto = true + isAutoTimeWindow = preset == TimeWindowPreset.AUTO + timeWindowSec = preset.displaySpanSec + panOffsetSec = 0.0 + timeViewport.reset() + val latest = latestTimestamp() + if (!isAutoTimeWindow && latest != null) { + timeViewport.pin(latest - timeWindowSec, latest, latest) + } + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setXAxisAutoMode(preset: TimeWindowPreset) { + setTimeWindow(preset) + } + + @Synchronized + override fun setXAxisManualMode() { + rememberNavigationHome() + val current = buildSnapshot(screenWidthPx) + timeWindowSec = current.timeMaxSec - current.timeMinSec + timeViewport.pin(current.timeMinSec, current.timeMaxSec) + panOffsetSec = 0.0 + isXAxisAuto = false + xAxisPreset = TimeWindowPreset.MANUAL + isAutoTimeWindow = false + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setYAxisAuto(isAuto: Boolean) { + for ((pos, config) in _axesMap) { + _axesMap[pos] = config.copy(isAuto = isAuto) + } + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setYAxisRange(minVal: Double, maxVal: Double) { + if (!isValidChartAxisRange(minVal, maxVal)) return + for ((pos, config) in _axesMap) { + _axesMap[pos] = config.copy(minVal = minVal, maxVal = maxVal, isAuto = false) + } + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setLegendPosition(position: Int) { + this.legendPosition = position.coerceIn(0, 3) + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setPaused(paused: Boolean) { + this.isPaused = paused + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + @Synchronized + override fun setCrosshairTimeSec(timeSec: Double?) { + this.crosshairTimeSec = timeSec?.takeIf { it.isFinite() } + cursorSamples = this.crosshairTimeSec?.let { time -> + _seriesMap.mapValues { (_, series) -> CursorSamples(series.buffer, series.buffer.captureAt(time)) } + } ?: emptyMap() + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setAxisRange(position: AxisPosition, minVal: Double, maxVal: Double, isAuto: Boolean) { + if (!isValidChartAxisRange(minVal, maxVal)) return + val axis = _axesMap[position] ?: return + _axesMap[position] = axis.copy( + minVal = minVal, + maxVal = maxVal, + isAuto = isAuto + ) + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setAxisMinMax(position: AxisPosition, minVal: Double, maxVal: Double) { + if (!isValidChartAxisRange(minVal, maxVal)) return + val axis = _axesMap[position] ?: return + _axesMap[position] = axis.copy(minVal = minVal, maxVal = maxVal) + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setAxisAuto(position: AxisPosition, isAuto: Boolean) { + val axis = _axesMap[position] ?: return + _axesMap[position] = axis.copy(isAuto = isAuto) + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setSeriesVisible(signalId: String, isVisible: Boolean) { + val series = _seriesMap[signalId] ?: return + _seriesMap[signalId] = series.copy(isVisible = isVisible) + updateSeriesList() + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + fun assignSeriesAxis(signalId: String, position: AxisPosition) { + val series = _seriesMap[signalId] ?: return + val oldPosition = series.axisPosition + _seriesMap[signalId] = series.copy(axisPosition = position) + + // 새 축에 이동한 신호의 색상과 단위 적용 + val targetAxis = _axesMap[position] ?: defaultPrimaryAxis.copy(position = position) + _axesMap[position] = targetAxis.copy(isVisible = true, color = series.color, unit = series.unit) + + // 이전 축에 다른 신호가 남아있다면 그 신호의 색상으로 축 색상 복원 + if (oldPosition != position) { + val remaining = _seriesMap.values.find { it.axisPosition == oldPosition } + if (remaining != null) { + _axesMap[oldPosition]?.let { + _axesMap[oldPosition] = it.copy(color = remaining.color, unit = remaining.unit) + } + } else if (oldPosition != AxisPosition.LEFT_0) { + _axesMap[oldPosition]?.let { + _axesMap[oldPosition] = it.copy(isVisible = false) + } + } else { + _axesMap[AxisPosition.LEFT_0]?.let { + _axesMap[AxisPosition.LEFT_0] = it.copy(color = 0xFF424242L) + } + } + } + + updateSeriesList() + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + override fun setSeriesColor(signalId: String, color: Long) { + val series = _seriesMap[signalId] ?: return + _seriesMap[signalId] = series.copy(color = color) + _axesMap[series.axisPosition]?.let { axis -> + _axesMap[series.axisPosition] = axis.copy(color = color) + } + updateSeriesList() + markDirty() + } + + override fun setSeriesThickness(signalId: String, thickness: Float) { + val series = _seriesMap[signalId] ?: return + _seriesMap[signalId] = series.copy(lineWidthDp = thickness) + updateSeriesList() + markDirty() + } + + override fun setSeriesDashStyle(signalId: String, dashStyle: Int) { + val series = _seriesMap[signalId] ?: return + _seriesMap[signalId] = series.copy(dashStyle = dashStyle) + updateSeriesList() + markDirty() + } + + override fun setSeriesInterpolationMode(signalId: String, mode: InterpolationMode) { + val series = _seriesMap[signalId] ?: return + _seriesMap[signalId] = series.copy(interpolationMode = mode) + updateSeriesList() + markDirty() + } + + @Synchronized + override fun panTimeWindow(deltaSec: Double) { + if (!deltaSec.isFinite() || deltaSec == 0.0) return + // Already following: a forward drag must not freeze or replace the live preset. + if (deltaSec > 0.0 && isXAxisAuto) return + rememberNavigationHome() + if (isXAxisAuto) setXAxisManualMode() + val baseRight = timeViewport.currentRange?.max + ?: (buildSnapshot(screenWidthPx).timeMaxSec - panOffsetSec) + val latest = latestTimestamp() + val maxOffset = ((latest ?: baseRight) - baseRight).coerceAtLeast(0.0) + val nextOffset = panOffsetSec + deltaSec + if (!nextOffset.isFinite()) return + panOffsetSec = nextOffset.coerceAtMost(maxOffset) + val resumedFollowing = deltaSec > 0.0 && followLatestIfReached(baseRight + panOffsetSec, latest) + // Measurement stop/disconnect cancels the render loop. Navigation must still publish + // frames from retained samples; markDirty alone has no consumer in that state. + if (resumedFollowing || renderJob?.isActive != true) { + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + } + markDirty() + } + + /** Resume rolling at the current zoom, without replacing the double-tap restore target. */ + private fun followLatestIfReached(right: Double, latest: Double?): Boolean { + if (latest == null) return false + val tolerance = maxOf(timeWindowSec * 1e-6, Math.ulp(latest) * 2) + if (right < latest - tolerance) return false + panOffsetSec = 0.0 + isXAxisAuto = true + isAutoTimeWindow = false + xAxisPreset = TimeWindowPreset.MANUAL // Custom span: adaptive ticks and continuous rolling. + timeViewport.pin(latest - timeWindowSec, latest, latest) + return true + } + + @Synchronized + fun zoomTimeWindow(factor: Float, anchorFraction: Double) { + if (!factor.isFinite() || factor <= 0f || !anchorFraction.isFinite()) return + if (kotlin.math.abs(factor - 1f) < 1e-6f) return + val snapshot = buildSnapshot(screenWidthPx) + val oldSpan = snapshot.timeMaxSec - snapshot.timeMinSec + if (!oldSpan.isFinite() || oldSpan <= 0) return + rememberNavigationHome() + val newSpan = (oldSpan / factor).coerceIn(0.05, 86400.0) + val anchor = snapshot.timeMinSec + oldSpan * anchorFraction.coerceIn(0.0, 1.0) + val newMin = anchor - newSpan * anchorFraction.coerceIn(0.0, 1.0) + timeViewport.pin(newMin, newMin + newSpan) + panOffsetSec = 0.0 + timeWindowSec = newSpan + isAutoTimeWindow = false + isXAxisAuto = false + xAxisPreset = TimeWindowPreset.MANUAL + // Keep the pinch anchor in history; snap to live only when its right edge is reached. + followLatestIfReached(newMin + newSpan, latestTimestamp()) + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + /** Change presentation only: retain samples, axes, colors, visibility and ordering. */ + fun setGraphInterpolationMode(mode: InterpolationMode) { + graphInterpolationMode = mode + _seriesMap.replaceAll { _, series -> series.copy(interpolationMode = mode) } + updateSeriesList() // Publish immediately, including while measuring is paused. + markDirty() + } + + @Synchronized + override fun resetPan() { + // Double-tap restores the pre-navigation X scale and returns to live tracking. + // Y-axis configuration, signal styles and buffered samples remain untouched. + val home = navigationHome ?: NavigationHome(timeWindowSec, xAxisPreset, isAutoTimeWindow) + timeWindowSec = home.span + xAxisPreset = home.preset + isAutoTimeWindow = home.auto + isXAxisAuto = true + navigationHome = null + panOffsetSec = 0.0 + crosshairTimeSec = null + cursorSamples = emptyMap() + timeViewport.reset() + val latest = latestTimestamp() + if (!isAutoTimeWindow && latest != null) { + timeViewport.pin(latest - timeWindowSec, latest, latest) + } + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + markDirty() + } + + @Synchronized + override fun clearAllData() { + navigationHome = null + for (series in _seriesMap.values) { + series.buffer.clear() + } + crosshairTimeSec = null + cursorSamples = emptyMap() + panOffsetSec = 0.0 + if (xAxisPreset.tickIntervalSec != null) timeWindowSec = xAxisPreset.resetSpanSec + timeViewport.reset() + markDirty() + } + + private fun updateSeriesList() { + _seriesListFlow.value = seriesOrder.mapNotNull { _seriesMap[it] } + _renderSnapshotFlow.value = buildSnapshot(screenWidthPx) + } + + @Synchronized + fun buildSnapshot(targetScreenWidthPx: Int): ChartRenderSnapshot { + val activeSeries = seriesOrder.mapNotNull { _seriesMap[it] } + + var globalMaxT = -Double.MAX_VALUE + var globalMinT = Double.MAX_VALUE + var hasData = false + + for (s in activeSeries) { + val latest = s.buffer.getLatestTimestamp() + val oldest = s.buffer.getOldestTimestamp() + if (latest != null && oldest != null) { + hasData = true + if (latest > globalMaxT) globalMaxT = latest + if (oldest < globalMinT) globalMinT = oldest + } + } + + val nowSec = System.currentTimeMillis() / 1000.0 + val win = if (timeWindowSec > 0.0) timeWindowSec else 60.0 + val range = timeViewport.resolve( + oldest = globalMinT.takeIf { hasData }, latest = globalMaxT.takeIf { hasData }, + now = nowSec, span = win, preset = xAxisPreset, auto = isAutoTimeWindow, + follow = isXAxisAuto, width = targetScreenWidthPx.toFloat() + ) + val tMin = range.min + panOffsetSec + val tMax = range.max + panOffsetSec + + val seriesRenderDataList = ArrayList(activeSeries.size) + val dynamicAxesMap = HashMap(_axesMap) + + if (!dynamicAxesMap.containsKey(AxisPosition.LEFT_0)) { + dynamicAxesMap[AxisPosition.LEFT_0] = defaultPrimaryAxis + } else { + val cur0 = dynamicAxesMap[AxisPosition.LEFT_0]!! + if (!cur0.isVisible && activeSeries.none { it.axisPosition == AxisPosition.LEFT_0 }) { + dynamicAxesMap[AxisPosition.LEFT_0] = cur0.copy(isVisible = true) + } + } + + val axisMinMaxMap = mutableMapOf>() + + for (s in activeSeries) { + val range = if (s.isVisible) DownsamplingAlgorithms.findVisibleIndexRange(s.buffer, tMin, tMax) else null + val points = if (range != null) { + DownsamplingAlgorithms.minMaxDownsample( + buffer = s.buffer, + firstIdx = range.first, + lastIdx = range.second, + tMin = tMin, + tMax = tMax, + screenWidthPx = targetScreenWidthPx, + mode = s.interpolationMode + ) + } else { + emptyList() + } + + var minVal: Double? = null + var maxVal: Double? = null + val finitePoints = points.filter { it.second.isFinite() } + if (finitePoints.isNotEmpty()) { + var mn = Double.MAX_VALUE + var mx = -Double.MAX_VALUE + for (p in finitePoints) { + if (p.second < mn) mn = p.second + if (p.second > mx) mx = p.second + } + minVal = mn + maxVal = mx + + val current = axisMinMaxMap[s.axisPosition] + if (current == null) { + axisMinMaxMap[s.axisPosition] = Pair(mn, mx) + } else { + axisMinMaxMap[s.axisPosition] = Pair( + kotlin.math.min(current.first, mn), + kotlin.math.max(current.second, mx) + ) + } + } + + seriesRenderDataList.add( + SeriesRenderData( + signalId = s.signalId, + signalName = s.signalName, + axisPosition = s.axisPosition, + color = s.color, + unit = s.unit, + lineWidthDp = s.lineWidthDp, + dashStyle = s.dashStyle, + interpolationMode = s.interpolationMode, + isVisible = s.isVisible, + points = points, + latestValue = s.buffer.getLatestValue(), + minVisibleValue = minVal, + maxVisibleValue = maxVal, + cursorValue = crosshairTimeSec?.let { time -> + cursorSamples[s.signalId]?.takeIf { it.buffer === s.buffer }?.let { captured -> + interpolateChartValue(captured.points, time, s.interpolationMode) + } + } + ) + ) + } + + // 각 개별 축별 Min/Max를 독립 축 설정에 반영 (다중축 완벽 지원) + if (axisMinMaxMap.isNotEmpty()) { + for ((pos, minMax) in axisMinMaxMap) { + val axisConfig = dynamicAxesMap[pos] + if (axisConfig != null && axisConfig.isAuto) { + val mn = minMax.first + val mx = minMax.second + val margin = if (mx - mn > 1e-6) (mx - mn) * 0.1 else 1.0 + if (isValidChartAxisRange(mn - margin, mx + margin)) { + dynamicAxesMap[pos] = axisConfig.copy(minVal = mn - margin, maxVal = mx + margin) + } + } + } + } + + return ChartRenderSnapshot( + timeMinSec = tMin, + timeMaxSec = tMax, + seriesList = seriesRenderDataList, + axesConfig = dynamicAxesMap, + crosshairTimeSec = crosshairTimeSec, + isPaused = isPaused, + legendPosition = legendPosition, + isXAxisAuto = isXAxisAuto, + xAxisPreset = xAxisPreset, + isYAxisAuto = allBoundAxesAuto() + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/MultiAxisChartController.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/MultiAxisChartController.kt new file mode 100644 index 0000000..f401634 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/MultiAxisChartController.kt @@ -0,0 +1,80 @@ +package com.dct.app.domain.usecase.chart + +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.ChartAxisConfig +import com.dct.app.domain.model.chart.ChartRenderSnapshot +import com.dct.app.domain.model.chart.ChartSignalSeries +import com.dct.app.domain.model.chart.InterpolationMode +import com.dct.app.domain.model.chart.TimeWindowPreset +import kotlinx.coroutines.flow.StateFlow + +/** + * 멀티축 차트 엔진 컨트롤러 인터페이스 (PROJECT.md 제5.3절 사양 완벽 준수) + */ +interface MultiAxisChartController { + + fun bindSignal(signalId: String, axisIndex: Int, isLeft: Boolean, color: Int) + + fun bindSignal( + signalId: String, + signalName: String, + axisPosition: AxisPosition, + color: Long, + unit: String = "", + interpolationMode: InterpolationMode = InterpolationMode.LINEAR, + lineWidthDp: Float = 2.0f, + minVal: Double? = null, + maxVal: Double? = null + ) + + fun unbindSignal(signalId: String) + + fun addSample(signalId: String, timestampMs: Long, rawValue: Double) + + fun addSampleSec(signalId: String, timestampSec: Double, rawValue: Double) + + fun setTimeWindow(durationMs: Long) + + fun setTimeWindow(preset: TimeWindowPreset) + + fun setPaused(paused: Boolean) + + fun setCrosshairTimeSec(timeSec: Double?) + + fun setAxisRange(position: AxisPosition, minVal: Double, maxVal: Double, isAuto: Boolean) + + fun setAxisMinMax(position: AxisPosition, minVal: Double, maxVal: Double) + + fun setAxisAuto(position: AxisPosition, isAuto: Boolean) + + fun setSeriesVisible(signalId: String, isVisible: Boolean) + + fun setSeriesColor(signalId: String, color: Long) + + fun setSeriesThickness(signalId: String, thickness: Float) + + fun setSeriesDashStyle(signalId: String, dashStyle: Int) + + fun setSeriesInterpolationMode(signalId: String, mode: InterpolationMode) + + fun setLegendPosition(position: Int) // 0: Right, 1: Top, 2: Bottom + + fun setXAxisAutoMode(preset: TimeWindowPreset) + + fun setXAxisManualMode() + + fun setYAxisAuto(isAuto: Boolean) + + fun setYAxisRange(minVal: Double, maxVal: Double) + + fun panTimeWindow(deltaSec: Double) + + /** Restore the pre-pan/zoom time scale and resume following the latest data. */ + fun resetPan() + + fun clearAllData() + + val renderSnapshotFlow: StateFlow + + val seriesListFlow: StateFlow> +} diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ThrottledBatchRenderer.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ThrottledBatchRenderer.kt new file mode 100644 index 0000000..1020fb9 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ThrottledBatchRenderer.kt @@ -0,0 +1,8 @@ +package com.dct.app.domain.usecase.chart + +import com.dct.app.core.common.DispatcherProvider + +/** + * ThrottledBatchRenderer 별칭 및 래퍼 클래스 (ChartBatchRenderer와 100% 호환) + */ +typealias ThrottledBatchRenderer = ChartBatchRenderer diff --git a/Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationInspectorView.kt b/Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationInspectorView.kt new file mode 100644 index 0000000..aabc82d --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationInspectorView.kt @@ -0,0 +1,113 @@ +package com.dct.app.presentation.calibration + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * 캘리브레이션 인스펙터 복합 뷰 (상수 튜너 / 보정 맵 에디터 통합) + */ +@Composable +fun CalibrationInspectorView( + viewModel: CalibrationViewModel, + modifier: Modifier = Modifier +) { + val uiState by viewModel.uiState.collectAsState() + + Surface( + modifier = modifier.fillMaxSize(), + color = Color(0xFF12141C) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // 상단 상태 피드백 알림 바 (피드백 메시지가 있을 경우) + if (uiState.statusMessage != null) { + Surface( + color = if (uiState.isSuccessFeedback) Color(0xFF1B3828) else Color(0xFF3E1E1E), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = uiState.statusMessage ?: "", + style = MaterialTheme.typography.bodySmall.copy( + color = if (uiState.isSuccessFeedback) Color(0xFF81C784) else Color(0xFFFF8A80), + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + + // 모드별 컨텐츠 렌더링 + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + when (uiState.mode) { + CalibrationEditorMode.CONSTANT -> { + uiState.constantState?.let { constState -> + ConstantTuningView( + state = constState, + onTextChanged = { viewModel.onConstantTextChanged(it) }, + onSliderChanged = { viewModel.onConstantSliderChanged(it) }, + onReset = { viewModel.resetConstant() }, + onCommit = { viewModel.commitConstant() }, + modifier = Modifier.fillMaxSize() + ) + } + } + CalibrationEditorMode.COMPENSATION_MAP -> { + uiState.mapState?.let { mapState -> + MapTableEditor( + state = mapState, + onPointUpdated = { index, bkp, raw, phy -> + viewModel.updateMapPoint(index, bkp, raw, phy) + }, + onAddPoint = { viewModel.addMapPoint() }, + onDeletePoint = { viewModel.deleteMapPoint(it) }, + onSelectPoint = { viewModel.selectMapPoint(it) }, + onReset = { viewModel.resetMap() }, + onCommit = { viewModel.commitMap() }, + modifier = Modifier.fillMaxSize() + ) + } + } + CalibrationEditorMode.SIGNAL_INSPECTOR, CalibrationEditorMode.NONE -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + tint = Color(0xFF44474F), + modifier = Modifier.size(40.dp) + ) + Text( + text = "신호 탐색기에서 상수 또는 보정 맵을 선택하십시오.", + color = Color(0xFF9096A2), + fontSize = 13.sp + ) + } + } + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationModels.kt b/Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationModels.kt new file mode 100644 index 0000000..f4a5bae --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationModels.kt @@ -0,0 +1,49 @@ +package com.dct.app.presentation.calibration + +import com.dct.app.domain.model.mdb.CompensationMap +import com.dct.app.domain.model.mdb.MapPoint +import com.dct.app.domain.model.mdb.MdbConstant + +/** + * 캘리브레이션 편집 모드 + */ +enum class CalibrationEditorMode { + CONSTANT, + COMPENSATION_MAP, + SIGNAL_INSPECTOR, + NONE +} + +/** + * 상수 튜닝 상태 모델 + */ +data class ConstantTuningState( + val constant: MdbConstant, + val currentValue: Double, + val textValue: String, + val isValid: Boolean = true, + val errorMessage: String? = null, + val isCommitted: Boolean = false, + val isCommitting: Boolean = false +) { + val isDirty: Boolean get() = currentValue != constant.data +} + +/** + * 1D/2D 보정 맵 편집 상태 모델 + */ +data class MapEditingState( + val map: CompensationMap, + val editedPoints: List, + val selectedPointIndex: Int? = null, + val minVal: Double = map.yMin, + val maxVal: Double = map.yMax, + val cvsA: Double = map.cvsA, + val cvsB: Double = map.cvsB, + val description: String = map.description, + val isCommitted: Boolean = false, + val isCommitting: Boolean = false, + val errorMessage: String? = null +) { + val isDirty: Boolean get() = editedPoints != map.points || cvsA != map.cvsA || cvsB != map.cvsB +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationViewModel.kt b/Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationViewModel.kt new file mode 100644 index 0000000..37eb23f --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationViewModel.kt @@ -0,0 +1,437 @@ +package com.dct.app.presentation.calibration + +import androidx.lifecycle.ViewModel +import com.dct.app.core.common.DctResult +import com.dct.app.core.common.DispatcherProvider +import com.dct.app.domain.model.mdb.CompensationMap +import com.dct.app.domain.model.mdb.MapPoint +import com.dct.app.domain.model.mdb.MdbConstant +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.repository.MdbRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * 캘리브레이션 전체 UI 상태 + */ +data class CalibrationUiState( + val mode: CalibrationEditorMode = CalibrationEditorMode.NONE, + val constantState: ConstantTuningState? = null, + val mapState: MapEditingState? = null, + val selectedSignal: SignalDefinition? = null, + val statusMessage: String? = null, + val isSuccessFeedback: Boolean = false +) + +/** + * 캘리브레이션 뷰모델 + */ +class CalibrationViewModel( + private val mdbRepository: MdbRepository, + private val dispatchers: DispatcherProvider, + private val scope: CoroutineScope = CoroutineScope(dispatchers.mainImmediate + SupervisorJob()) +) : ViewModel() { + + private val _uiState = MutableStateFlow(CalibrationUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _userFeedbackEvents = MutableSharedFlow(extraBufferCapacity = 8) + val userFeedbackEvents: SharedFlow = _userFeedbackEvents.asSharedFlow() + + /** + * 캘리브레이션 편집기 상태 초기화 + */ + fun clear() { + _uiState.update { CalibrationUiState() } + } + + // ========================================================================= + // 1. 상수(Constant) 튜닝 + // ========================================================================= + + /** + * 편집할 상수 선택 + */ + fun selectConstant(constant: MdbConstant) { + val state = ConstantTuningState( + constant = constant, + currentValue = constant.data, + textValue = constant.data.toString(), + isValid = true + ) + _uiState.update { + it.copy( + mode = CalibrationEditorMode.CONSTANT, + constantState = state, + statusMessage = null + ) + } + } + + /** + * 상수 텍스트 입력값 변경 + */ + fun onConstantTextChanged(newText: String) { + _uiState.update { current -> + val constState = current.constantState ?: return@update current + val parsed = newText.toDoubleOrNull() + if (parsed == null) { + current.copy( + constantState = constState.copy( + textValue = newText, + isValid = false, + errorMessage = "유효한 숫자를 입력하십시오." + ) + ) + } else { + val min = constState.constant.min + val max = constState.constant.max + val isInRange = parsed in min..max + val errorMsg = if (!isInRange) "허용 범위 [${min} ~ ${max}]를 벗어났습니다." else null + current.copy( + constantState = constState.copy( + currentValue = parsed, + textValue = newText, + isValid = isInRange, + errorMessage = errorMsg + ) + ) + } + } + } + + /** + * 상수 슬라이더 이동에 따른 값 변경 + */ + fun onConstantSliderChanged(newValue: Double) { + _uiState.update { current -> + val constState = current.constantState ?: return@update current + val formatted = String.format("%.2f", newValue).toDoubleOrNull() ?: newValue + current.copy( + constantState = constState.copy( + currentValue = formatted, + textValue = formatted.toString(), + isValid = true, + errorMessage = null + ) + ) + } + } + + /** + * 상수 초기값으로 리셋 + */ + fun resetConstant() { + _uiState.update { current -> + val constState = current.constantState ?: return@update current + current.copy( + constantState = constState.copy( + currentValue = constState.constant.data, + textValue = constState.constant.data.toString(), + isValid = true, + errorMessage = null + ) + ) + } + } + + /** + * 상수 Live Commit (MDB 캐시 업데이트 및 저장) + */ + fun commitConstant() { + val constState = _uiState.value.constantState ?: return + if (!constState.isValid) return + + scope.launch(dispatchers.io) { + _uiState.update { it.copy(constantState = it.constantState?.copy(isCommitting = true)) } + + val result = mdbRepository.updateConstant( + name = constState.constant.name, + value = constState.currentValue + ) + + when (result) { + is DctResult.Success -> { + val updatedConstant = constState.constant.copy(data = constState.currentValue) + _uiState.update { + it.copy( + constantState = constState.copy( + constant = updatedConstant, + isCommitting = false, + isCommitted = true + ), + statusMessage = "'${updatedConstant.name}' 상수가 성공적으로 반영되었습니다.", + isSuccessFeedback = true + ) + } + _userFeedbackEvents.emit("상수 ${updatedConstant.name} = ${constState.currentValue} 적용 완료") + } + is DctResult.Failure -> { + _uiState.update { + it.copy( + constantState = it.constantState?.copy( + isCommitting = false, + errorMessage = "상수 수정 실패: ${result.error.message}" + ), + statusMessage = "수정 실패", + isSuccessFeedback = false + ) + } + } + is DctResult.Loading -> { + // 로딩 상태 유지 + } + } + } + } + + // ========================================================================= + // 2. 1D/2D 보정 맵(Compensation Map) 편집 + // ========================================================================= + + /** + * 편집할 보정 맵 선택 + */ + fun selectCompensationMap(map: CompensationMap) { + val state = MapEditingState( + map = map, + editedPoints = map.points.toMutableList() + ) + _uiState.update { + it.copy( + mode = CalibrationEditorMode.COMPENSATION_MAP, + mapState = state, + statusMessage = null + ) + } + } + + /** + * 보정 맵 포인트 선택 + */ + fun selectMapPoint(index: Int?) { + _uiState.update { current -> + val mapState = current.mapState ?: return@update current + current.copy(mapState = mapState.copy(selectedPointIndex = index)) + } + } + + /** + * 보정 맵 포인트 수정 (X: Breakpoint, Raw Data, Physical Value) + * $Y = A \cdot X + B$ 공식 기반으로 Raw Data와 Physical Value를 상호 변환 + */ + fun updateMapPoint(index: Int, newBkp: Int, newRawData: Int? = null, newPhy: Double? = null) { + _uiState.update { current -> + val mapState = current.mapState ?: return@update current + if (index !in mapState.editedPoints.indices) return@update current + + val oldPoint = mapState.editedPoints[index] + val a = mapState.cvsA + val b = mapState.cvsB + + val (finalData, finalPhy) = when { + newRawData != null -> { + // Raw Data 변경 -> Physical 자동 계산: Phy = Raw * A + B + val calcPhy = (newRawData.toDouble() * a) + b + Pair(newRawData, calcPhy) + } + newPhy != null -> { + // Physical 변경 -> Raw Data 자동 역계산: Raw = (Phy - B) / A + val calcRaw = if (a != 0.0) ((newPhy - b) / a).toInt() else 0 + Pair(calcRaw, newPhy) + } + else -> Pair(oldPoint.data, oldPoint.phy) + } + + val updatedPoint = oldPoint.copy( + bkp = newBkp, + data = finalData, + phy = finalPhy + ) + + val updatedList = mapState.editedPoints.toMutableList() + updatedList[index] = updatedPoint + + current.copy( + mapState = mapState.copy(editedPoints = updatedList) + ) + } + } + + /** + * 새 보정 맵 포인트 추가 (마지막 행 뒤에 추가) + */ + fun addMapPoint() { + _uiState.update { current -> + val mapState = current.mapState ?: return@update current + val newIdx = mapState.editedPoints.size + 1 + val lastBkp = mapState.editedPoints.lastOrNull()?.bkp ?: 0 + val lastRaw = mapState.editedPoints.lastOrNull()?.data ?: 0 + val newBkp = lastBkp + 100 + val newRaw = lastRaw + 10 + val newPhy = (newRaw.toDouble() * mapState.cvsA) + mapState.cvsB + + val newPoint = MapPoint( + idx = newIdx, + bkp = newBkp, + data = newRaw, + phy = newPhy + ) + + current.copy( + mapState = mapState.copy( + editedPoints = mapState.editedPoints + newPoint, + selectedPointIndex = mapState.editedPoints.size + ) + ) + } + } + + /** + * 특정 인덱스의 보정 맵 포인트 삭제 + */ + fun deleteMapPoint(index: Int) { + _uiState.update { current -> + val mapState = current.mapState ?: return@update current + if (index !in mapState.editedPoints.indices || mapState.editedPoints.size <= 2) { + // 최소 2개 포인트는 보정 커브를 위해 유지 + return@update current + } + + val mutableList = mapState.editedPoints.toMutableList() + mutableList.removeAt(index) + + // 인덱스 재정렬 + val reindexed = mutableList.mapIndexed { idx, pt -> pt.copy(idx = idx + 1) } + + current.copy( + mapState = mapState.copy( + editedPoints = reindexed, + selectedPointIndex = null + ) + ) + } + } + + /** + * 보정 맵 포인트 원래대로 리셋 + */ + fun resetMap() { + _uiState.update { current -> + val mapState = current.mapState ?: return@update current + current.copy( + mapState = mapState.copy( + editedPoints = mapState.map.points.toMutableList(), + cvsA = mapState.map.cvsA, + cvsB = mapState.map.cvsB, + selectedPointIndex = null, + errorMessage = null + ) + ) + } + } + + /** + * 보정 맵 Live Commit (MDB 캐시 업데이트 및 저장) + */ + fun commitMap() { + val mapState = _uiState.value.mapState ?: return + + scope.launch(dispatchers.io) { + _uiState.update { it.copy(mapState = it.mapState?.copy(isCommitting = true)) } + + // 1. 포인트 목록 저장 + val updatePointsResult = mdbRepository.updateCompensationMap( + name = mapState.map.yName, + points = mapState.editedPoints + ) + + // 2. 메타데이터 저장 + val updateMetaResult = mdbRepository.updateCompensationMetadata( + isTypeA = mapState.map.isTypeA, + yName = mapState.map.yName, + min = mapState.minVal, + max = mapState.maxVal, + cvsA = mapState.cvsA, + cvsB = mapState.cvsB, + description = mapState.description + ) + + if (updatePointsResult is DctResult.Success && updateMetaResult is DctResult.Success) { + val updatedMap = mapState.map.copy( + points = mapState.editedPoints, + cvsA = mapState.cvsA, + cvsB = mapState.cvsB, + yMin = mapState.minVal, + yMax = mapState.maxVal, + description = mapState.description + ) + _uiState.update { + it.copy( + mapState = mapState.copy( + map = updatedMap, + isCommitting = false, + isCommitted = true + ), + statusMessage = "보정 맵 '${updatedMap.yName}'이 성공적으로 반영되었습니다.", + isSuccessFeedback = true + ) + } + _userFeedbackEvents.emit("보정 맵 ${updatedMap.yName} ${updatedMap.points.size}개 포인트 커밋 완료") + } else { + val err = (updatePointsResult as? DctResult.Failure)?.error + ?: (updateMetaResult as? DctResult.Failure)?.error + _uiState.update { + it.copy( + mapState = it.mapState?.copy( + isCommitting = false, + errorMessage = "보정 맵 저장 실패: ${err?.message}" + ), + statusMessage = "저장 실패", + isSuccessFeedback = false + ) + } + } + } + } + + // ========================================================================= + // 3. 신호(Signal) 인스펙터 선택 + // ========================================================================= + + fun selectSignal(signal: SignalDefinition) { + _uiState.update { + it.copy( + mode = CalibrationEditorMode.SIGNAL_INSPECTOR, + selectedSignal = signal, + statusMessage = null + ) + } + } + + fun clearSelection() { + _uiState.update { + it.copy( + mode = CalibrationEditorMode.NONE, + constantState = null, + mapState = null, + selectedSignal = null, + statusMessage = null + ) + } + } + + override fun onCleared() { + super.onCleared() + scope.cancel() + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/calibration/ConstantTuningView.kt b/Android/app/src/main/java/com/dct/app/presentation/calibration/ConstantTuningView.kt new file mode 100644 index 0000000..20af271 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/calibration/ConstantTuningView.kt @@ -0,0 +1,263 @@ +package com.dct.app.presentation.calibration + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * 단일 상수(MdbConstant) 정밀 튜닝 뷰 (슬라이더 + 정밀 직접 수치 입력) + */ +@Composable +fun ConstantTuningView( + state: ConstantTuningState, + onTextChanged: (String) -> Unit, + onSliderChanged: (Double) -> Unit, + onReset: () -> Unit, + onCommit: () -> Unit, + modifier: Modifier = Modifier +) { + val constant = state.constant + val minVal = constant.min.toFloat() + val maxVal = constant.max.toFloat() + val currentVal = state.currentValue.toFloat().coerceIn(minVal, maxVal) + + Card( + modifier = modifier + .fillMaxWidth() + .padding(8.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1E222D)), + shape = RoundedCornerShape(10.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 1. 헤더: 상수명 및 ATTR/주소 뱃지 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + tint = Color(0xFF81C784), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = constant.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = Color(0xFFE1E2E8), + fontSize = 16.sp + ) + ) + } + + Surface( + color = Color(0xFF2C303E), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "Addr: 0x${constant.addr.toString(16).uppercase().padStart(4, '0')}", + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + color = Color(0xFF9096A2), + fontSize = 11.sp + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + // 2. 설명 및 메타데이터 + if (constant.description.isNotBlank()) { + Text( + text = constant.description, + style = MaterialTheme.typography.bodySmall.copy( + color = Color(0xFF9096A2), + fontSize = 12.sp + ) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "타입: ${constant.attr.displayName}", + style = MaterialTheme.typography.labelSmall.copy(color = Color(0xFF6B7280), fontSize = 11.sp) + ) + Text( + text = "허용 범위: [${constant.min} ~ ${constant.max}]", + style = MaterialTheme.typography.labelSmall.copy( + color = Color(0xFF6B7280), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + ) + } + + HorizontalDivider(color = Color(0xFF2C303E), thickness = 1.dp) + + // 3. 정밀 직접 수치 입력 필드 + OutlinedTextField( + value = state.textValue, + onValueChange = onTextChanged, + label = { Text("파라미터 값 (Direct Input)", color = Color(0xFF9096A2)) }, + isError = !state.isValid, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF4C8DFF), + unfocusedBorderColor = Color(0xFF2C303E), + errorBorderColor = Color(0xFFE53935), + focusedTextColor = Color(0xFFE1E2E8), + unfocusedTextColor = Color(0xFFE1E2E8), + errorTextColor = Color(0xFFFF8A80) + ) + ) + + // 유효성 에러 메시지 + if (!state.isValid && state.errorMessage != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = Color(0xFFE53935), + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = state.errorMessage, + style = MaterialTheme.typography.labelSmall.copy( + color = Color(0xFFE53935), + fontSize = 11.sp + ) + ) + } + } + + // 4. 슬라이더 바 (Min ~ Max) + if (minVal < maxVal) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "${constant.min}", + style = MaterialTheme.typography.labelSmall.copy( + color = Color(0xFF6B7280), + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + ) + Text( + text = "현재: ${String.format("%.2f", state.currentValue)}", + style = MaterialTheme.typography.labelSmall.copy( + color = Color(0xFF81C784), + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + ) + Text( + text = "${constant.max}", + style = MaterialTheme.typography.labelSmall.copy( + color = Color(0xFF6B7280), + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + ) + } + + Slider( + value = currentVal, + onValueChange = { onSliderChanged(it.toDouble()) }, + valueRange = minVal..maxVal, + colors = SliderDefaults.colors( + thumbColor = Color(0xFF4C8DFF), + activeTrackColor = Color(0xFF4C8DFF), + inactiveTrackColor = Color(0xFF2C303E) + ) + ) + } + } + + // 5. 버튼 그룹: 리셋 & Live Commit + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = onReset, + enabled = state.isDirty && !state.isCommitting, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color(0xFF9096A2) + ), + shape = RoundedCornerShape(6.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("초기화", fontSize = 12.sp) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = onCommit, + enabled = state.isValid && state.isDirty && !state.isCommitting, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF0052CC), + disabledContainerColor = Color(0xFF2C303E) + ), + shape = RoundedCornerShape(6.dp) + ) { + if (state.isCommitting) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + color = Color.White, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("적용 중...", fontSize = 12.sp) + } else { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Live Commit (적용)", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/calibration/MapTableEditor.kt b/Android/app/src/main/java/com/dct/app/presentation/calibration/MapTableEditor.kt new file mode 100644 index 0000000..e8e0917 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/calibration/MapTableEditor.kt @@ -0,0 +1,457 @@ +package com.dct.app.presentation.calibration + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.mdb.MapPoint + +/** + * 1D/2D 보정 맵 테이블 & 커브 그래픽 편집기 + */ +@Composable +fun MapTableEditor( + state: MapEditingState, + onPointUpdated: (index: Int, bkp: Int, raw: Int?, phy: Double?) -> Unit, + onAddPoint: () -> Unit, + onDeletePoint: (index: Int) -> Unit, + onSelectPoint: (index: Int?) -> Unit, + onReset: () -> Unit, + onCommit: () -> Unit, + modifier: Modifier = Modifier +) { + val map = state.map + + Column( + modifier = modifier + .fillMaxSize() + .background(Color(0xFF12141C)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + // 1. 맵 헤더 메타데이터 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1E222D)), + shape = RoundedCornerShape(8.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.GridOn, + contentDescription = null, + tint = Color(0xFFBA68C8), + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = map.yName, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = Color(0xFFE1E2E8), + fontSize = 15.sp + ) + ) + } + + Surface( + color = if (map.isTypeA) Color(0xFF2E3B52) else Color(0xFF3E2D4C), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = if (map.isTypeA) "Type A (PxDy)" else "Type B (PyDx)", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + color = if (map.isTypeA) Color(0xFF8AB4F8) else Color(0xFFCE93D8), + fontSize = 11.sp + ), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "X축: ${map.xName} (Addr: 0x${map.xAddr.toString(16).uppercase()})", + style = MaterialTheme.typography.labelSmall.copy( + color = Color(0xFF9096A2), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + ) + Text( + text = "변환식: Y = (${map.cvsA} * X) + ${map.cvsB}", + style = MaterialTheme.typography.labelSmall.copy( + color = Color(0xFF81C784), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + ) + } + } + } + + // 2. 보정 커브 시각화 Canvas (상단 축약 차트) + Card( + modifier = Modifier + .fillMaxWidth() + .height(130.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF161922)), + shape = RoundedCornerShape(8.dp) + ) { + MapCurveCanvas( + points = state.editedPoints, + selectedIndex = state.selectedPointIndex, + modifier = Modifier.fillMaxSize().padding(8.dp) + ) + } + + // 3. 포인트 테이블 툴바 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "보정 포인트 목록 (${state.editedPoints.size}개)", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + color = Color(0xFFE1E2E8), + fontSize = 13.sp + ) + ) + + Button( + onClick = onAddPoint, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C303E)), + shape = RoundedCornerShape(6.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.height(28.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = Color(0xFF4C8DFF), + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("포인트 추가", color = Color(0xFF4C8DFF), fontSize = 11.sp) + } + } + + // 4. 테이블 헤더 + Surface( + color = Color(0xFF1E222D), + shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("#", color = Color(0xFF9096A2), fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(30.dp)) + Text("X (BKP)", color = Color(0xFF9096A2), fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text("Raw Data", color = Color(0xFF9096A2), fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text("Physical (Y)", color = Color(0xFF9096A2), fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1.2f)) + Text("삭제", color = Color(0xFF9096A2), fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp)) + } + } + + // 5. 포인트 행 목록 (LazyColumn) + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + itemsIndexed( + items = state.editedPoints, + key = { idx, item -> "pt_${item.idx}_$idx" } + ) { index, point -> + val isSelected = state.selectedPointIndex == index + MapPointTableRow( + point = point, + index = index, + isSelected = isSelected, + onSelect = { onSelectPoint(index) }, + onUpdate = { bkp, raw, phy -> onPointUpdated(index, bkp, raw, phy) }, + onDelete = { onDeletePoint(index) }, + canDelete = state.editedPoints.size > 2 + ) + } + } + + // 6. 하단 버튼 그룹: 초기화 & Live Commit + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = onReset, + enabled = state.isDirty && !state.isCommitting, + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFF9096A2)), + shape = RoundedCornerShape(6.dp) + ) { + Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("초기화", fontSize = 12.sp) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = onCommit, + enabled = state.isDirty && !state.isCommitting, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF0052CC), + disabledContainerColor = Color(0xFF2C303E) + ), + shape = RoundedCornerShape(6.dp) + ) { + if (state.isCommitting) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), color = Color.White, strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(6.dp)) + Text("MDB 저장 중...", fontSize = 12.sp) + } else { + Icon(imageVector = Icons.Default.CheckCircle, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Live Commit (MDB 반영)", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + } + } +} + +/** + * 테이블 행 아이템 + */ +@Composable +private fun MapPointTableRow( + point: MapPoint, + index: Int, + isSelected: Boolean, + onSelect: () -> Unit, + onUpdate: (bkp: Int, raw: Int?, phy: Double?) -> Unit, + onDelete: () -> Unit, + canDelete: Boolean +) { + var bkpText by remember(point.bkp) { mutableStateOf(point.bkp.toString()) } + var rawText by remember(point.data) { mutableStateOf(point.data.toString()) } + var phyText by remember(point.phy) { mutableStateOf(String.format("%.2f", point.phy)) } + + val rowBg = if (isSelected) Color(0xFF24334C) else Color(0xFF1A1C24) + + Surface( + color = rowBg, + shape = RoundedCornerShape(4.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 1. 인덱스 + Text( + text = "${point.idx}", + color = Color(0xFF9096A2), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier.width(30.dp) + ) + + // 2. Breakpoint (BKP) 입력 + OutlinedTextField( + value = bkpText, + onValueChange = { + bkpText = it + it.toIntOrNull()?.let { newBkp -> onUpdate(newBkp, null, null) } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f).height(40.dp).padding(end = 4.dp), + textStyle = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = Color(0xFFE1E2E8) + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF4C8DFF), + unfocusedBorderColor = Color(0xFF2C303E), + focusedContainerColor = Color(0xFF1E222D), + unfocusedContainerColor = Color(0xFF1E222D) + ) + ) + + // 3. Raw Data 입력 + OutlinedTextField( + value = rawText, + onValueChange = { + rawText = it + it.toIntOrNull()?.let { newRaw -> onUpdate(point.bkp, newRaw, null) } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f).height(40.dp).padding(end = 4.dp), + textStyle = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = Color(0xFF81C784) + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF4C8DFF), + unfocusedBorderColor = Color(0xFF2C303E), + focusedContainerColor = Color(0xFF1E222D), + unfocusedContainerColor = Color(0xFF1E222D) + ) + ) + + // 4. Physical Value 입력 + OutlinedTextField( + value = phyText, + onValueChange = { + phyText = it + it.toDoubleOrNull()?.let { newPhy -> onUpdate(point.bkp, null, newPhy) } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1.2f).height(40.dp).padding(end = 4.dp), + textStyle = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = Color(0xFF4C8DFF) + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF4C8DFF), + unfocusedBorderColor = Color(0xFF2C303E), + focusedContainerColor = Color(0xFF1E222D), + unfocusedContainerColor = Color(0xFF1E222D) + ) + ) + + // 5. 삭제 버튼 + IconButton( + onClick = onDelete, + enabled = canDelete, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.DeleteOutline, + contentDescription = "포인트 삭제", + tint = if (canDelete) Color(0xFFE53935) else Color(0xFF44474F), + modifier = Modifier.size(16.dp) + ) + } + } + } +} + +/** + * 2D 보정 커브 Canvas 시각화 + */ +@Composable +private fun MapCurveCanvas( + points: List, + selectedIndex: Int?, + modifier: Modifier = Modifier +) { + Canvas(modifier = modifier) { + if (points.isEmpty()) return@Canvas + + val w = size.width + val h = size.height + val padding = 20f + + val minX = points.minOfOrNull { it.bkp }?.toFloat() ?: 0f + val maxX = (points.maxOfOrNull { it.bkp }?.toFloat() ?: 100f).coerceAtLeast(minX + 1f) + val minY = points.minOfOrNull { it.phy }?.toFloat() ?: 0f + val maxY = (points.maxOfOrNull { it.phy }?.toFloat() ?: 100f).coerceAtLeast(minY + 1f) + + fun toCanvasX(bkp: Int): Float { + val ratio = (bkp.toFloat() - minX) / (maxX - minX) + return padding + ratio * (w - padding * 2) + } + + fun toCanvasY(phy: Double): Float { + val ratio = (phy.toFloat() - minY) / (maxY - minY) + return h - padding - ratio * (h - padding * 2) + } + + // 그리드 라인 (수평 3개) + for (i in 0..2) { + val yPos = padding + i * ((h - padding * 2) / 2f) + drawLine( + color = Color(0xFF2C303E), + start = Offset(padding, yPos), + end = Offset(w - padding, yPos), + strokeWidth = 1f + ) + } + + // 보정 곡선 경로 그리기 + val path = Path() + points.forEachIndexed { index, pt -> + val cx = toCanvasX(pt.bkp) + val cy = toCanvasY(pt.phy) + if (index == 0) { + path.moveTo(cx, cy) + } else { + path.lineTo(cx, cy) + } + } + + drawPath( + path = path, + color = Color(0xFF4C8DFF), + style = Stroke(width = 2.5f) + ) + + // 포인트 원 그리기 + points.forEachIndexed { index, pt -> + val cx = toCanvasX(pt.bkp) + val cy = toCanvasY(pt.phy) + val isSelected = index == selectedIndex + + drawCircle( + color = if (isSelected) Color(0xFFFFD600) else Color(0xFF8AB4F8), + radius = if (isSelected) 6f else 4f, + center = Offset(cx, cy) + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/chart/ChartCursorTooltip.kt b/Android/app/src/main/java/com/dct/app/presentation/chart/ChartCursorTooltip.kt new file mode 100644 index 0000000..d308120 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/chart/ChartCursorTooltip.kt @@ -0,0 +1,25 @@ +package com.dct.app.presentation.chart + +import com.dct.app.domain.model.chart.SeriesRenderData +import java.util.Locale + +internal data class ChartCursorTooltipRow( + val name: String, + val value: String, + val valueColorArgb: Int +) + +internal fun chartCursorTooltipRows( + seriesList: List +): List = seriesList.filter { it.isVisible }.map { series -> + val value = series.cursorValue?.takeIf { it.isFinite() } + ChartCursorTooltipRow( + name = series.signalName, + value = if (value == null) "N/A" else { + String.format(Locale.US, "%.2f", value) + + series.unit.takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty() + }, + // Values must stay legible on the dark tooltip, regardless of the signal color. + valueColorArgb = 0xFFFFFFFF.toInt() + ) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/chart/GraphYAxisDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/chart/GraphYAxisDialog.kt new file mode 100644 index 0000000..fcaaa7d --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/chart/GraphYAxisDialog.kt @@ -0,0 +1,156 @@ +package com.dct.app.presentation.chart + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.dct.app.domain.model.chart.chartAxisInputText +import com.dct.app.domain.model.chart.isValidChartAxisRange + +/** + * PC C++ CGraphYAxisDlg 모달 대화상자 (영문 UI) + */ +@Composable +fun GraphYAxisDialog( + signalName: String, + currentMin: Double, + currentMax: Double, + isAuto: Boolean, + onDismiss: () -> Unit, + onConfirm: (minVal: Double, maxVal: Double, isAuto: Boolean) -> Unit +) { + var minText by remember { mutableStateOf(chartAxisInputText(currentMin)) } + var maxText by remember { mutableStateOf(chartAxisInputText(currentMax)) } + var autoScale by remember { mutableStateOf(isAuto) } + var errorMessage by remember { mutableStateOf(null) } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.White, + shadowElevation = 8.dp, + modifier = Modifier.width(320.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + // Header + Text( + text = "Y-Axis Range ($signalName)", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF0F172A) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Auto Scale Checkbox + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = autoScale, + onCheckedChange = { autoScale = it } + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Auto Scale", + fontSize = 13.sp, + color = Color(0xFF334155) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Max Field + OutlinedTextField( + value = maxText, + onValueChange = { + maxText = it + errorMessage = null + }, + label = { Text("Max") }, + enabled = !autoScale, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(10.dp)) + + // Min Field + OutlinedTextField( + value = minText, + onValueChange = { + minText = it + errorMessage = null + }, + label = { Text("Min") }, + enabled = !autoScale, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + if (errorMessage != null) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = errorMessage ?: "", + fontSize = 11.sp, + color = Color(0xFFEF4444) + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Bottom Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text("Cancel", color = Color(0xFF64748B)) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + if (autoScale) { + onConfirm(currentMin, currentMax, true) + onDismiss() + } else { + val parsedMin = minText.toDoubleOrNull() + val parsedMax = maxText.toDoubleOrNull() + + if (parsedMin == null || parsedMax == null || !parsedMin.isFinite() || !parsedMax.isFinite()) { + errorMessage = "Please enter valid numbers." + } else if (!isValidChartAxisRange(parsedMin, parsedMax)) { + errorMessage = "Min must be less than Max." + } else { + onConfirm(parsedMin, parsedMax, false) + onDismiss() + } + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2563EB) + ) + ) { + Text("OK", color = Color.White) + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphCanvas.kt b/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphCanvas.kt new file mode 100644 index 0000000..9a06824 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphCanvas.kt @@ -0,0 +1,1017 @@ +package com.dct.app.presentation.chart + +import android.graphics.Paint +import android.graphics.Rect +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.getValue +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import com.dct.app.presentation.monitor.components.LocalWorkspaceTouchNavigation +import com.dct.app.core.theme.toAndroidColorInt +import com.dct.app.core.theme.toComposeColor +import com.dct.app.domain.model.chart.ChartAxisTicks +import com.dct.app.domain.model.chart.placeAxisLabels +import com.dct.app.domain.model.chart.forEachChartVertex +import com.dct.app.domain.model.chart.chartLineWidthPx +import com.dct.app.domain.model.chart.chartAxisTickText +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.ChartAxisConfig +import com.dct.app.domain.model.chart.ChartRenderSnapshot +import com.dct.app.domain.model.chart.InterpolationMode +import com.dct.app.domain.model.chart.MultiAxisCoordinateTransformer +import com.dct.app.domain.model.chart.PlotBounds +import com.dct.app.domain.model.chart.SeriesRenderData +import java.util.Locale +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow + +/** + * 실시간 시계열 차트 Canvas 컴포넌트 + * + * - PC 레거시 100% 호환 범례: 솔리드 블랙 3px 직각 섀도우 + 순백색 배경 + 1px 블랙 테두리 + 18px 컬러 실선 + 볼드 검정 텍스트 + * - 대시 스타일 5종 지원: Solid, Dash, Dot, Dash dot, Dash dot dot + * - 롱프레스 / 우클릭 컨텍스트 메뉴 연동 + */ +@Composable +fun MultiXGraphCanvas( + snapshot: ChartRenderSnapshot, + modifier: Modifier = Modifier, + isMultiAxis: Boolean = true, + onCrosshairTimeChange: ((Double?) -> Unit)? = null, + onRemoveSignal: ((String) -> Unit)? = null, + onPanTime: ((Double) -> Unit)? = null, + onZoomTime: ((Float, Double) -> Unit)? = null, + onResetPan: (() -> Unit)? = null, + onOpenContextMenu: ((Offset) -> Unit)? = null +) { + // Menus retain hidden metadata; geometry/legend/hit testing only see displayed signals. + val visibleSnapshot = remember(snapshot) { snapshot.copy(seriesList = snapshot.seriesList.filter { it.isVisible }) } + val currentSnapshot by rememberUpdatedState(visibleSnapshot) + val currentPan by rememberUpdatedState(onPanTime) + val currentZoom by rememberUpdatedState(onZoomTime) + val currentReset by rememberUpdatedState(onResetPan) + val workspaceNavigation = LocalWorkspaceTouchNavigation.current + val graphOrigin = remember { arrayOf(Offset.Zero) } + val textPaint = remember { + Paint().apply { + isAntiAlias = true + textSize = 19f + color = android.graphics.Color.DKGRAY + } + } + + val tooltipTextPaint = remember { + Paint().apply { + isAntiAlias = true + textSize = 22f + color = android.graphics.Color.WHITE + } + } + + val legendPaint = remember { + Paint().apply { + isAntiAlias = true + textSize = 18f + color = android.graphics.Color.BLACK + typeface = android.graphics.Typeface.DEFAULT_BOLD + } + } + + val defaultAxis = remember { + ChartAxisConfig( + position = AxisPosition.LEFT_0, + minVal = -50.0, + maxVal = 100.0, + isAuto = true, + color = 0xFF424242L, + unit = "", + label = "LEFT_0", + tickCount = 5, + offsetDp = 0f, + isVisible = true + ) + } + + Box( + modifier = modifier + .fillMaxSize() + .background(Color(0xFFECEFF1)) + .onGloballyPositioned { graphOrigin[0] = it.positionInRoot() } + .pointerInput(isMultiAxis) { + detectTapGestures( + onLongPress = { offset -> + onOpenContextMenu?.invoke(offset) + }, + onDoubleTap = { + currentReset?.invoke() + }, + onTap = { offset -> + val snapshot = currentSnapshot + val visibleLeftAxes = if (isMultiAxis) { + val usedPositions = snapshot.seriesList.filter { it.isVisible }.map { it.axisPosition }.toSet() + val axes = snapshot.axesConfig.values.filter { it.isVisible && it.position.isLeft && (usedPositions.isEmpty() || it.position in usedPositions) }.sortedBy { it.position.index } + if (axes.isEmpty()) listOf(defaultAxis) else axes + } else { + listOf(snapshot.axesConfig[AxisPosition.LEFT_0] ?: snapshot.axesConfig.values.firstOrNull() ?: defaultAxis) + } + val approxLegendWidth = if (snapshot.seriesList.isNotEmpty() && snapshot.legendPosition == 0) { + val maxW = snapshot.seriesList.maxOfOrNull { legendPaint.measureText(it.signalName) } ?: 60f + maxW + 36f + } else 0f + + val bounds = MultiAxisCoordinateTransformer.calculatePlotBounds( + canvasWidth = size.width.toFloat(), + canvasHeight = size.height.toFloat(), + leftAxisCount = visibleLeftAxes.size.coerceAtLeast(1), + rightAxisCount = visibleRightAxes(snapshot, isMultiAxis).size, + isMultiAxis = isMultiAxis, + hasLegend = snapshot.seriesList.isNotEmpty() && snapshot.legendPosition != 3, + legendPosition = snapshot.legendPosition, + legendWidthPx = approxLegendWidth, + seriesCount = snapshot.seriesList.size + ) + + if (bounds.isDrawable && bounds.width >= 120f && bounds.height >= 60f && offset.x in bounds.left..bounds.right && offset.y in bounds.top..bounds.bottom) { + val time = MultiAxisCoordinateTransformer.screenXToTime( + screenX = offset.x, + tMinSec = snapshot.timeMinSec, + tMaxSec = snapshot.timeMaxSec, + plotLeft = bounds.left, + plotWidth = bounds.width + ) + onCrosshairTimeChange?.invoke(time) + } else { + onCrosshairTimeChange?.invoke(null) + } + } + ) + } + .pointerInput(isMultiAxis, workspaceNavigation) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val snapshot = currentSnapshot + val visibleLeftAxes = if (isMultiAxis) { + val usedPositions = snapshot.seriesList.filter { it.isVisible }.map { it.axisPosition }.toSet() + val axes = snapshot.axesConfig.values.filter { it.isVisible && it.position.isLeft && (usedPositions.isEmpty() || it.position in usedPositions) }.sortedBy { it.position.index } + if (axes.isEmpty()) listOf(defaultAxis) else axes + } else { + listOf(snapshot.axesConfig[AxisPosition.LEFT_0] ?: snapshot.axesConfig.values.firstOrNull() ?: defaultAxis) + } + val hasLegend = snapshot.seriesList.isNotEmpty() && snapshot.legendPosition != 3 + val approxLegendWidth = if (hasLegend && snapshot.legendPosition == 0) { + val maxW = snapshot.seriesList.maxOfOrNull { legendPaint.measureText(it.signalName) } ?: 60f + maxW + 36f + } else 0f + + val bounds = MultiAxisCoordinateTransformer.calculatePlotBounds( + canvasWidth = size.width.toFloat(), + canvasHeight = size.height.toFloat(), + leftAxisCount = visibleLeftAxes.size.coerceAtLeast(1), + rightAxisCount = visibleRightAxes(snapshot, isMultiAxis).size, + isMultiAxis = isMultiAxis, + hasLegend = hasLegend, + legendPosition = snapshot.legendPosition, + legendWidthPx = approxLegendWidth, + seriesCount = snapshot.seriesList.size + ) + + // 터치 다운이 차트 플롯 영역 내부일 때 부모 스크롤을 막고 X축 패닝을 우선 수행 + if (bounds.isDrawable && bounds.width >= 120f && bounds.height >= 60f && down.position.x in bounds.left..bounds.right && down.position.y in bounds.top..bounds.bottom) { + if (currentZoom != null && workspaceNavigation?.ownsTouches == false) { + workspaceNavigation.canZoomAtRoot = { rootPoint -> + val local = rootPoint - graphOrigin[0] + local.x in bounds.left..bounds.right && local.y in bounds.top..bounds.bottom + } + workspaceNavigation.zoomAtRoot = { zoom, rootCenter -> + val anchor = ((rootCenter.x - graphOrigin[0].x - bounds.left) / bounds.width).toDouble() + currentZoom?.invoke(zoom, anchor) + } + } + val currentPointerId = down.id + var pendingDragX = 0f + var isDragging = false + var wasPinching = false + var workspaceCaptured = false + while (true) { + val event = awaitPointerEvent() + if (workspaceNavigation?.ownsTouches == true) workspaceCaptured = true + if (workspaceCaptured) { + if (event.changes.none { it.pressed }) break + continue + } + if (event.changes.count { it.pressed } >= 2) { + wasPinching = true + val zoom = event.calculateZoom() + val anchor = ((event.calculateCentroid().x - bounds.left) / bounds.width).toDouble() + currentZoom?.invoke(zoom, anchor) + event.changes.forEach { it.consume() } + continue + } + // Lifting one finger after a pinch must not cause a sudden pan. + if (wasPinching) { + event.changes.forEach { it.consume() } + if (event.changes.none { it.pressed }) break + continue + } + val change = event.changes.firstOrNull { it.id == currentPointerId } ?: break + if (!change.pressed) break + + var dragAmountX = change.position.x - change.previousPosition.x + if (!isDragging) { + pendingDragX += dragAmountX + if (kotlin.math.abs(pendingDragX) <= viewConfiguration.touchSlop) continue + isDragging = true + dragAmountX = pendingDragX - kotlin.math.sign(pendingDragX) * viewConfiguration.touchSlop + } + if (dragAmountX != 0f && bounds.width > 10f) { + val tSpan = (currentSnapshot.timeMaxSec - currentSnapshot.timeMinSec).coerceAtLeast(0.05) + val deltaSec = -(dragAmountX / bounds.width.toDouble()) * tSpan + currentPan?.invoke(deltaSec) + change.consume() + } + } + } + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize().clipToBounds()) { + val snapshot = visibleSnapshot + val primaryAxis = if (!isMultiAxis && snapshot.isYAxisAuto && snapshot.seriesList.isNotEmpty()) { + val allPoints = snapshot.seriesList.filter { it.isVisible }.flatMap { it.points }.filter { it.second.isFinite() } + if (allPoints.isNotEmpty()) { + val mn = allPoints.minOf { it.second } + val mx = allPoints.maxOf { it.second } + val margin = if (mx - mn > 1e-6) (mx - mn) * 0.1 else 1.0 + (snapshot.axesConfig[AxisPosition.LEFT_0] ?: defaultAxis).copy( + minVal = mn - margin, + maxVal = mx + margin + ) + } else { + snapshot.axesConfig[AxisPosition.LEFT_0] ?: defaultAxis + } + } else { + snapshot.axesConfig[AxisPosition.LEFT_0] ?: defaultAxis + } + + val visibleLeftAxes = if (isMultiAxis) { + val usedPositions = snapshot.seriesList.filter { it.isVisible }.map { it.axisPosition }.toSet() + val axes = snapshot.axesConfig.values.filter { it.isVisible && it.position.isLeft && (usedPositions.isEmpty() || it.position in usedPositions) }.sortedBy { it.position.index } + if (axes.isEmpty()) listOf(defaultAxis) else axes + } else { + listOf(primaryAxis) + } + + val hasLegend = snapshot.seriesList.isNotEmpty() && snapshot.legendPosition != 3 + val approxLegendWidth = if (hasLegend && snapshot.legendPosition == 0) { + val maxW = snapshot.seriesList.maxOfOrNull { legendPaint.measureText(it.signalName) } ?: 60f + maxW + 36f + } else 0f + + val bounds = MultiAxisCoordinateTransformer.calculatePlotBounds( + canvasWidth = size.width, + canvasHeight = size.height, + leftAxisCount = visibleLeftAxes.size.coerceAtLeast(1), + rightAxisCount = visibleRightAxes(snapshot, isMultiAxis).size, + isMultiAxis = isMultiAxis, + hasLegend = hasLegend, + legendPosition = snapshot.legendPosition, + legendWidthPx = approxLegendWidth, + seriesCount = snapshot.seriesList.size + ) + + // A tiny window cannot fit axes/legends; never draw a synthetic out-of-canvas plot. + if (!bounds.isDrawable || bounds.width < 120f || bounds.height < 60f) return@Canvas + + // 1. 플롯 영역 내부 흰색 배경 채우기 + drawRect( + color = Color.White, + topLeft = Offset(bounds.left, bounds.top), + size = Size(bounds.width, bounds.height) + ) + + // 2. 창 높이에 맞춘 동적 Y축 틱 및 수평 점선 그리드 렌더링 + drawYAxesAndGrids( + visibleAxes = visibleLeftAxes + visibleRightAxes(snapshot, isMultiAxis), + bounds = bounds, + isMultiAxis = isMultiAxis, + textPaint = textPaint, + snapshot = snapshot + ) + + // 3. 플롯 영역 외곽 4면 테두리선 (Solid Black 1.5px) + drawRect( + color = Color.Black, + topLeft = Offset(bounds.left, bounds.top), + size = Size(bounds.width, bounds.height), + style = Stroke(width = 1.5f) + ) + + // 4. 창 너비에 맞춘 동적 X축 틱 및 시간 라벨 렌더링 (30초 정수 단위) + drawXAxisAndLabels( + snapshot = snapshot, + bounds = bounds, + textPaint = textPaint + ) + + // 5. 시계열 신호 파형 렌더링 (5가지 Dash Style 지원 및 플롯 영역 클리핑) + clipRect( + left = bounds.left, + top = bounds.top, + right = bounds.right, + bottom = bounds.bottom + ) { + for (series in snapshot.seriesList) { + if (!series.isVisible || series.points.size < 2) continue + + val axis = if (isMultiAxis) { + snapshot.axesConfig[series.axisPosition] ?: visibleLeftAxes.firstOrNull() ?: defaultAxis + } else { + primaryAxis + } + + drawSeriesPath( + series = series, + axis = axis, + tMinSec = snapshot.timeMinSec, + tMaxSec = snapshot.timeMaxSec, + bounds = bounds + ) + } + } + + // 6. PC 레거시 스타일 범례 렌더링 (Right, Top, Bottom, None) + if (snapshot.seriesList.isNotEmpty()) { + when (snapshot.legendPosition) { + 0 -> { // Right + drawRightLegendBox( + seriesList = snapshot.seriesList, + bounds = bounds.copy(right = bounds.right + visibleRightAxes(snapshot, isMultiAxis).size * 54f), + canvasWidth = size.width, + canvasHeight = size.height, + legendPaint = legendPaint + ) + } + 1 -> { // Top + drawTopLegendBox( + seriesList = snapshot.seriesList, + bounds = bounds, + canvasWidth = size.width, + legendPaint = legendPaint + ) + } + 2 -> { // Bottom + drawBottomLegendBox( + seriesList = snapshot.seriesList, + bounds = bounds, + canvasWidth = size.width, + canvasHeight = size.height, + legendPaint = legendPaint + ) + } + 3 -> { // None (Hide Legend) + // 범례 숨김 + } + } + } + + // 7. 실시간 크로스헤어(십자선) 및 다중 수치 툴팁 렌더링 + snapshot.crosshairTimeSec?.let { crosshairT -> + if (crosshairT in snapshot.timeMinSec..snapshot.timeMaxSec) { + drawCrosshairAndTooltip( + crosshairTimeSec = crosshairT, + snapshot = snapshot, + bounds = bounds, + tooltipTextPaint = tooltipTextPaint + ) + } + } + } + } +} + +/** + * 좌측 Y축 선, 틱 마크 및 수평 그리드 점선 렌더링 + */ +private fun DrawScope.drawYAxesAndGrids( + visibleAxes: List, + bounds: PlotBounds, + isMultiAxis: Boolean, + textPaint: Paint, + snapshot: ChartRenderSnapshot +) { + if (visibleAxes.isEmpty()) return + + val gridDashEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 3f), 0f) + + if (!isMultiAxis) { + val primaryAxis = visibleAxes.first() + val axisX = bounds.left + + drawLine( + color = Color.Black, + start = Offset(axisX, bounds.top), + end = Offset(axisX, bounds.bottom), + strokeWidth = 2f + ) + + val ticks = calculateDynamicYTicks(primaryAxis.minVal, primaryAxis.maxVal, isMultiAxis = false, plotHeight = bounds.height) + for (v in ticks) { + val y = MultiAxisCoordinateTransformer.valueToScreenY( + value = v, + vMin = primaryAxis.minVal, + vMax = primaryAxis.maxVal, + plotTop = bounds.top, + plotHeight = bounds.height + ) + + if (y in (bounds.top + 1f)..(bounds.bottom - 1f)) { + drawLine( + color = Color(0xFF808080), + start = Offset(bounds.left, y), + end = Offset(bounds.right, y), + strokeWidth = 1f, + pathEffect = gridDashEffect + ) + } + + drawLine( + color = Color.Black, + start = Offset(axisX - 4f, y), + end = Offset(axisX, y), + strokeWidth = 1.5f + ) + + val labelStr = chartAxisTickText(v, ticks) + val fontMetrics = textPaint.fontMetrics + val baseline = y - (fontMetrics.ascent + fontMetrics.descent) / 2f + textPaint.color = android.graphics.Color.DKGRAY + textPaint.textAlign = android.graphics.Paint.Align.RIGHT + + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawText(labelStr, axisX - 6f, baseline, textPaint) + } + } + } else { + val axisCount = visibleAxes.size + for (i in 0 until axisCount) { + val axis = visibleAxes[i] + // 해당 축에 바인딩된 실제 신호가 있다면 그 신호의 고유 색상을 최우선으로 일치시킴 + val boundSeries = snapshot.seriesList.find { it.isVisible && it.axisPosition == axis.position } + ?: snapshot.seriesList.find { it.axisPosition == axis.position } + val effectiveColorLong = boundSeries?.color ?: axis.color + val axisColor = effectiveColorLong.toComposeColor() + val leftCount = visibleAxes.count { it.position.isLeft } + val isLeft = axis.position.isLeft + val axisX = if (isLeft) bounds.left - (leftCount - 1 - i) * 54f + else bounds.right + (i - leftCount) * 54f + + drawLine( + color = axisColor, + start = Offset(axisX, bounds.top), + end = Offset(axisX, bounds.bottom), + strokeWidth = 2f + ) + + val ticks = calculateDynamicYTicks(axis.minVal, axis.maxVal, isMultiAxis = true, plotHeight = bounds.height) + for (v in ticks) { + val y = MultiAxisCoordinateTransformer.valueToScreenY( + value = v, + vMin = axis.minVal, + vMax = axis.maxVal, + plotTop = bounds.top, + plotHeight = bounds.height + ) + + if (i == leftCount - 1 && y in (bounds.top + 1f)..(bounds.bottom - 1f)) { + drawLine( + color = Color(0xFF808080), + start = Offset(bounds.left, y), + end = Offset(bounds.right, y), + strokeWidth = 1f, + pathEffect = gridDashEffect + ) + } + + drawLine( + color = axisColor, + start = Offset(axisX + if (isLeft) -4f else 4f, y), + end = Offset(axisX, y), + strokeWidth = 1.5f + ) + + val labelStr = chartAxisTickText(v, ticks) + val fontMetrics = textPaint.fontMetrics + val baseline = y - (fontMetrics.ascent + fontMetrics.descent) / 2f + textPaint.color = effectiveColorLong.toAndroidColorInt() + textPaint.textAlign = if (isLeft) android.graphics.Paint.Align.RIGHT else android.graphics.Paint.Align.LEFT + + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawText(labelStr, axisX + if (isLeft) -6f else 6f, baseline, textPaint) + } + } + } + } +} + +/** + * X축 스마트 틱 계산 및 여유 있는 시간 라벨/수직 그리드 렌더링 + */ +private fun visibleRightAxes(snapshot: ChartRenderSnapshot, multiAxis: Boolean): List { + if (!multiAxis) return emptyList() + val used = snapshot.seriesList.filter { it.isVisible }.map { it.axisPosition }.toSet() + return snapshot.axesConfig.values.filter { it.isVisible && !it.position.isLeft && it.position in used }.sortedBy { it.position.index } +} + +private fun DrawScope.drawXAxisAndLabels( + snapshot: ChartRenderSnapshot, + bounds: PlotBounds, + textPaint: Paint +) { + val tMin = snapshot.timeMinSec + val tMax = snapshot.timeMaxSec + + val verticalGridDash = PathEffect.dashPathEffect(floatArrayOf(3f, 3f), 0f) + val ticks = ChartAxisTicks.time(tMin, tMax, bounds.width, snapshot.xAxisPreset) + val centers = ticks.values.map { t -> MultiAxisCoordinateTransformer.timeToScreenX(t, tMin, tMax, bounds.left, bounds.width) } + val labels = ticks.values.map { ticks.label(it) } + textPaint.textAlign = Paint.Align.LEFT + val placements = placeAxisLabels(centers, labels.map { textPaint.measureText(it) }, bounds.left, bounds.right) + .associateBy { it.index } + + for ((index, tSec) in ticks.values.withIndex()) { + val x = MultiAxisCoordinateTransformer.timeToScreenX( + timeSec = tSec, + tMinSec = tMin, + tMaxSec = tMax, + plotLeft = bounds.left, + plotWidth = bounds.width + ) + + if (x in (bounds.left + 1f)..(bounds.right - 1f)) { + drawLine( + color = Color(0xFF808080), + start = Offset(x, bounds.top), + end = Offset(x, bounds.bottom), + strokeWidth = 1f, + pathEffect = verticalGridDash + ) + } + + drawLine( + color = Color.Black, + start = Offset(x, bounds.bottom), + end = Offset(x, bounds.bottom + 4f), + strokeWidth = 1.5f + ) + + val placement = placements[index] ?: continue + textPaint.color = android.graphics.Color.DKGRAY + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawText(labels[index], placement.left, bounds.bottom + 16f, textPaint) + } + } +} + +private fun calculateDynamicYTicks(minVal: Double, maxVal: Double, isMultiAxis: Boolean, plotHeight: Float): List { + val vSpan = maxVal - minVal + if (vSpan <= 1e-6) return listOf(minVal) + + val pixelSpace = if (!isMultiAxis) 24f else 32f + val targetTicks = (plotHeight / pixelSpace).toInt().coerceIn(3, 16) + + val step: Double + if (!isMultiAxis && kotlin.math.abs(minVal - (-50.0)) < 1e-3 && kotlin.math.abs(maxVal - 100.0) < 1e-3) { + step = when { + targetTicks >= 12 -> 10.0 + targetTicks >= 6 -> 20.0 + targetTicks >= 4 -> 30.0 + else -> 50.0 + } + } else { + val rawStep = vSpan / targetTicks.toDouble() + val zeros = floor(log10(rawStep)).toInt() + val base = 10.0.pow(zeros.toDouble()) + step = when { + base >= rawStep -> base + base * 2.0 >= rawStep -> base * 2.0 + base * 5.0 >= rawStep -> base * 5.0 + else -> base * 10.0 + } + } + + val firstTick = ceil(minVal / step) * step + val ticks = mutableListOf() + var curr = firstTick + while (curr <= maxVal + 1e-6) { + if (curr >= minVal - 1e-6) { + ticks.add(curr) + } + curr += step + } + return if (ticks.isEmpty()) listOf(minVal, maxVal) else ticks +} + +/** + * 5가지 Dash Style에 대응하는 PathEffect 반환 (GraphWnd.cpp OnGraphPopupDashStyle) + */ +private fun getDashPathEffect(dashStyle: Int): PathEffect? { + return when (dashStyle) { + 1 -> PathEffect.dashPathEffect(floatArrayOf(10f, 6f), 0f) // Dash + 2 -> PathEffect.dashPathEffect(floatArrayOf(3f, 3f), 0f) // Dot + 3 -> PathEffect.dashPathEffect(floatArrayOf(10f, 4f, 3f, 4f), 0f) // Dash dot + 4 -> PathEffect.dashPathEffect(floatArrayOf(10f, 4f, 3f, 4f, 3f, 4f), 0f) // Dash dot dot + else -> null // Solid (0) + } +} + +/** + * 시계열 신호 패스 렌더링 + */ +private fun DrawScope.drawSeriesPath( + series: SeriesRenderData, + axis: ChartAxisConfig, + tMinSec: Double, + tMaxSec: Double, + bounds: PlotBounds +) { + if (series.points.size < 2) return + + val path = Path() + var isFirst = true + + val vMin = axis.minVal + val vMax = axis.maxVal + + forEachChartVertex(series.points, series.interpolationMode) { t, v, move -> + val screenX = MultiAxisCoordinateTransformer.timeToScreenX(t, tMinSec, tMaxSec, bounds.left, bounds.width) + val screenY = MultiAxisCoordinateTransformer.valueToScreenY(v, vMin, vMax, bounds.top, bounds.height) + if (!screenX.isFinite() || !screenY.isFinite()) { + isFirst = true + } else { + if (move || isFirst) path.moveTo(screenX, screenY) else path.lineTo(screenX, screenY) + isFirst = false + } + } + + drawPath( + path = path, + color = series.color.toComposeColor(), + style = Stroke( + width = chartLineWidthPx(series.lineWidthDp), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + pathEffect = getDashPathEffect(series.dashStyle) + ) + ) +} + +/** + * 우측 범례 (Right) + */ +private fun DrawScope.drawRightLegendBox( + seriesList: List, + bounds: PlotBounds, + canvasWidth: Float, + canvasHeight: Float, + legendPaint: Paint +) { + val itemHeight = 24f + val paddingH = 8f + val paddingV = 6f + val maxTextWidth = seriesList.maxOfOrNull { legendPaint.measureText(it.signalName) } ?: 50f + val boxWidth = maxTextWidth + 36f + val boxHeight = seriesList.size * itemHeight + paddingV * 2 + + val boxX = (bounds.right + 8f).coerceAtMost(canvasWidth - boxWidth - 6f).coerceAtLeast(bounds.left) + val boxY = (bounds.top + (bounds.height - boxHeight) / 2f).coerceIn(bounds.top, (canvasHeight - boxHeight - 6f).coerceAtLeast(bounds.top)) + + drawRect(color = Color.Black, topLeft = Offset(boxX + 3f, boxY + 3f), size = Size(boxWidth, boxHeight)) + drawRect(color = Color.White, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight)) + drawRect(color = Color.Black, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight), style = Stroke(width = 1f)) + + drawIntoCanvas { canvas -> + var currentY = boxY + paddingV + itemHeight / 2f + for (series in seriesList) { + val seriesColor = series.color.toComposeColor() + + drawLine( + color = seriesColor, + start = Offset(boxX + paddingH, currentY - 5f), + end = Offset(boxX + paddingH + 18f, currentY - 5f), + strokeWidth = 2.5f, + pathEffect = getDashPathEffect(series.dashStyle) + ) + + legendPaint.color = android.graphics.Color.BLACK + canvas.nativeCanvas.drawText(series.signalName, boxX + paddingH + 22f, currentY + 2f, legendPaint) + + currentY += itemHeight + } + } +} + +/** + * 상단 범례 (Top) + */ +private fun DrawScope.drawTopLegendBox( + seriesList: List, + bounds: PlotBounds, + canvasWidth: Float, + legendPaint: Paint +) { + val paddingH = 10f + val boxHeight = 26f + val boxY = (bounds.top - 32f).coerceAtLeast(4f) + + var totalItemsWidth = 0f + val itemWidths = seriesList.map { series -> + val textW = legendPaint.measureText(series.signalName) + val w = 18f + 6f + textW + 16f + totalItemsWidth += w + w + } + val boxWidth = (totalItemsWidth + paddingH * 2 - 16f).coerceIn(80f, bounds.width) + val boxX = bounds.left + (bounds.width - boxWidth) / 2f + + drawRect(color = Color.Black, topLeft = Offset(boxX + 3f, boxY + 3f), size = Size(boxWidth, boxHeight)) + drawRect(color = Color.White, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight)) + drawRect(color = Color.Black, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight), style = Stroke(width = 1f)) + + drawIntoCanvas { canvas -> + var currentX = boxX + paddingH + val itemCenterY = boxY + boxHeight / 2f + 4f + + for (i in seriesList.indices) { + val series = seriesList[i] + val seriesColor = series.color.toComposeColor() + + drawLine( + color = seriesColor, + start = Offset(currentX, itemCenterY - 9f), + end = Offset(currentX + 18f, itemCenterY - 9f), + strokeWidth = 2.5f, + pathEffect = getDashPathEffect(series.dashStyle) + ) + + legendPaint.color = android.graphics.Color.BLACK + canvas.nativeCanvas.drawText(series.signalName, currentX + 24f, itemCenterY - 3f, legendPaint) + + currentX += itemWidths[i] + if (currentX > boxX + boxWidth - 10f) break + } + } +} + +/** + * 하단 범례 (Bottom) - 신호 개수가 3개 이상으로 많아지면 2x2 (다열 2행) 형태로 표기 + */ +private fun DrawScope.drawBottomLegendBox( + seriesList: List, + bounds: PlotBounds, + canvasWidth: Float, + canvasHeight: Float, + legendPaint: Paint +) { + if (seriesList.isEmpty()) return + + val paddingH = 10f + val paddingV = 5f + val is2x2 = seriesList.size >= 3 + + if (!is2x2) { + // [1] 신호 1~2개: 단일 행 가로 정렬 + val boxHeight = 26f + val boxY = (bounds.bottom + 26f).coerceAtMost(canvasHeight - boxHeight - 4f) + + var totalItemsWidth = 0f + val itemWidths = seriesList.map { series -> + val textW = legendPaint.measureText(series.signalName) + val w = 18f + 6f + textW + 16f + totalItemsWidth += w + w + } + val boxWidth = (totalItemsWidth + paddingH * 2 - 16f).coerceIn(80f, bounds.width) + val boxX = bounds.left + (bounds.width - boxWidth) / 2f + + drawRect(color = Color.Black, topLeft = Offset(boxX + 3f, boxY + 3f), size = Size(boxWidth, boxHeight)) + drawRect(color = Color.White, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight)) + drawRect(color = Color.Black, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight), style = Stroke(width = 1f)) + + drawIntoCanvas { canvas -> + var currentX = boxX + paddingH + val itemCenterY = boxY + boxHeight / 2f + + for (i in seriesList.indices) { + val series = seriesList[i] + val seriesColor = series.color.toComposeColor() + + drawLine( + color = seriesColor, + start = Offset(currentX, itemCenterY), + end = Offset(currentX + 18f, itemCenterY), + strokeWidth = 2.5f, + pathEffect = getDashPathEffect(series.dashStyle) + ) + + legendPaint.color = android.graphics.Color.BLACK + canvas.nativeCanvas.drawText(series.signalName, currentX + 24f, itemCenterY + 5f, legendPaint) + + currentX += itemWidths[i] + } + } + } else { + // [2] 신호 3개 이상: 2x2 형태 (2행 N열 그리드 정렬) + // 4개: 2행 2열 (2x2) + // 3개: 1행 2개, 2행 1개 (2x2) + // 5~6개: 2행 3열 + val rowCount = 2 + val colCount = (seriesList.size + 1) / 2 + val itemHeight = 19f + val colSpacing = 16f + val boxHeight = paddingV * 2 + rowCount * itemHeight + 2f + val boxY = (bounds.bottom + 26f).coerceAtMost(canvasHeight - boxHeight - 4f) + + // 각 열(col)별 최대 너비 계산 + val colWidths = FloatArray(colCount) + for (i in seriesList.indices) { + val col = i % colCount + val textW = legendPaint.measureText(seriesList[i].signalName) + val itemW = 18f + 6f + textW + if (itemW > colWidths[col]) { + colWidths[col] = itemW + } + } + + val totalColsWidth = colWidths.sum() + (colCount - 1) * colSpacing + val boxWidth = (totalColsWidth + paddingH * 2).coerceIn(120f, bounds.width) + val boxX = bounds.left + (bounds.width - boxWidth) / 2f + + // 3px 직각 섀도우 + 순백색 배경 + 1px 검정 테두리 + drawRect(color = Color.Black, topLeft = Offset(boxX + 3f, boxY + 3f), size = Size(boxWidth, boxHeight)) + drawRect(color = Color.White, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight)) + drawRect(color = Color.Black, topLeft = Offset(boxX, boxY), size = Size(boxWidth, boxHeight), style = Stroke(width = 1f)) + + drawIntoCanvas { canvas -> + val colStartX = FloatArray(colCount) + var currentColX = boxX + paddingH + for (c in 0 until colCount) { + colStartX[c] = currentColX + currentColX += colWidths[c] + colSpacing + } + + for (i in seriesList.indices) { + val series = seriesList[i] + val seriesColor = series.color.toComposeColor() + + val col = i % colCount + val row = i / colCount + + val itemX = colStartX[col] + val itemCenterY = boxY + paddingV + row * itemHeight + itemHeight / 2f + + drawLine( + color = seriesColor, + start = Offset(itemX, itemCenterY), + end = Offset(itemX + 18f, itemCenterY), + strokeWidth = 2.5f, + pathEffect = getDashPathEffect(series.dashStyle) + ) + + legendPaint.color = android.graphics.Color.BLACK + canvas.nativeCanvas.drawText(series.signalName, itemX + 24f, itemCenterY + 5f, legendPaint) + } + } + } +} + +/** + * 실시간 크로스헤어(십자선) 및 다중 수치 툴팁 카드 렌더링 + */ +private fun DrawScope.drawCrosshairAndTooltip( + crosshairTimeSec: Double, + snapshot: ChartRenderSnapshot, + bounds: PlotBounds, + tooltipTextPaint: Paint +) { + val crosshairX = MultiAxisCoordinateTransformer.timeToScreenX( + timeSec = crosshairTimeSec, + tMinSec = snapshot.timeMinSec, + tMaxSec = snapshot.timeMaxSec, + plotLeft = bounds.left, + plotWidth = bounds.width + ).coerceIn(bounds.left, bounds.right) + + drawLine( + color = Color(0xFFDC2626), + start = Offset(crosshairX, bounds.top), + end = Offset(crosshairX, bounds.bottom), + strokeWidth = 1.5f + ) + + val tooltipLines = listOf( + ChartCursorTooltipRow("Time", formatTimestamp(crosshairTimeSec), android.graphics.Color.WHITE) + ) + chartCursorTooltipRows(snapshot.seriesList) + + val tooltipWidth = maxOf(220f, tooltipLines.maxOf { row -> + tooltipTextPaint.measureText(row.name) + tooltipTextPaint.measureText(row.value) + 36f + }).coerceAtMost((size.width - 16f).coerceAtLeast(1f)) + val itemH = tooltipTextPaint.fontSpacing + 6f + val tooltipHeight = tooltipLines.size * itemH + 16f + + var ttX = crosshairX + 12f + if (ttX + tooltipWidth > bounds.right) { + ttX = crosshairX - tooltipWidth - 12f + } + ttX = ttX.coerceIn(0f, (size.width - tooltipWidth).coerceAtLeast(0f)) + val ttY = bounds.top + 12f + + drawRoundRect( + color = Color(0xEE0F172A), + topLeft = Offset(ttX, ttY), + size = Size(tooltipWidth, tooltipHeight), + cornerRadius = CornerRadius(6f, 6f) + ) + + drawRoundRect( + color = Color(0xFF38BDF8), + topLeft = Offset(ttX, ttY), + size = Size(tooltipWidth, tooltipHeight), + cornerRadius = CornerRadius(6f, 6f), + style = Stroke(width = 1f) + ) + + drawIntoCanvas { canvas -> + var lineY = ttY + 8f - tooltipTextPaint.fontMetrics.top + for ((name, valStr, valueColorArgb) in tooltipLines) { + val originalTextSize = tooltipTextPaint.textSize + val valueWidth = tooltipTextPaint.measureText(valStr) + val availableValueWidth = (tooltipWidth - 20f).coerceAtLeast(1f) + if (valueWidth > availableValueWidth) { + tooltipTextPaint.textSize *= availableValueWidth / valueWidth + } + val valW = tooltipTextPaint.measureText(valStr) + tooltipTextPaint.color = valueColorArgb + canvas.nativeCanvas.drawText(valStr, ttX + tooltipWidth - 10f - valW, lineY, tooltipTextPaint) + tooltipTextPaint.textSize = originalTextSize + + // Reserve the value column; shorten only the signal name when the window is narrow. + val availableNameWidth = (tooltipWidth - valW - 36f).coerceAtLeast(0f) + val displayName = if (tooltipTextPaint.measureText(name) <= availableNameWidth) name else { + val ellipsisWidth = tooltipTextPaint.measureText("…") + if (availableNameWidth < ellipsisWidth) "" else { + val count = tooltipTextPaint.breakText(name, true, availableNameWidth - ellipsisWidth, null) + name.take(count) + "…" + } + } + tooltipTextPaint.color = android.graphics.Color.WHITE + canvas.nativeCanvas.drawText(displayName, ttX + 10f, lineY, tooltipTextPaint) + + lineY += itemH + } + } +} + +private fun formatTimestamp(timeSec: Double): String { + if (timeSec > 1_000_000_000.0) { + val sdf = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US) + return sdf.format(java.util.Date((timeSec * 1000.0).toLong())) + } + val totalSec = timeSec.toLong().coerceAtLeast(0L) + val hours = (totalSec / 3600) % 24 + val minutes = (totalSec % 3600) / 60 + val seconds = totalSec % 60 + return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphView.kt b/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphView.kt new file mode 100644 index 0000000..3b24842 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphView.kt @@ -0,0 +1,169 @@ +package com.dct.app.presentation.chart + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.core.theme.toComposeColor +import com.dct.app.domain.model.chart.TimeWindowPreset +import com.dct.app.domain.usecase.chart.ChartBatchRenderer + +/** + * 멀티축 실시간 차트 상위 뷰 (툴바 + MultiXGraphCanvas) + */ +@Composable +fun MultiXGraphView( + renderer: ChartBatchRenderer, + modifier: Modifier = Modifier +) { + val snapshot by renderer.renderSnapshotFlow.collectAsState() + val seriesList by renderer.seriesListFlow.collectAsState() + + Column( + modifier = modifier + .fillMaxSize() + .background(Color(0xFF0B1120)) + ) { + // 1. Chart Toolbar + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color(0xFF1E293B), + shadowElevation = 4.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 좌측: 활성 신호 칩 목록 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f) + ) { + for (s in seriesList) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background(Color(0xFF334155), RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(s.color.toComposeColor(), CircleShape) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${s.signalName} (${s.axisPosition.displayName})", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ), + color = Color.White + ) + } + } + } + + // 우측: 시간 프리셋 및 제어 버튼 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + val presets = listOf( + TimeWindowPreset.SEC_10, + TimeWindowPreset.SEC_30, + TimeWindowPreset.MIN_1, + TimeWindowPreset.MIN_5, + TimeWindowPreset.AUTO + ) + + for (preset in presets) { + OutlinedButton( + onClick = { renderer.setTimeWindow(preset) }, + modifier = Modifier.height(28.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color(0xFF94A3B8) + ) + ) { + Text(text = preset.displayName, fontSize = 11.sp) + } + } + + // 일시정지 / 재개 버튼 + IconButton( + onClick = { renderer.setPaused(!snapshot.isPaused) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (snapshot.isPaused) Icons.Default.PlayArrow else Icons.Default.Refresh, + contentDescription = if (snapshot.isPaused) "Resume" else "Pause", + tint = if (snapshot.isPaused) Color(0xFFF59E0B) else Color(0xFF10B981) + ) + } + + // 초기화 버튼 + IconButton( + onClick = { renderer.clearAllData() }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear", + tint = Color(0xFFEF4444) + ) + } + } + } + } + + // 2. Chart Canvas Body + MultiXGraphCanvas( + snapshot = snapshot, + modifier = Modifier + .fillMaxSize() + .weight(1f), + onCrosshairTimeChange = { time -> + renderer.setCrosshairTimeSec(time) + }, + onPanTime = { deltaSec -> renderer.panTimeWindow(deltaSec) }, + onZoomTime = { factor, anchor -> renderer.zoomTimeWindow(factor, anchor) }, + onResetPan = { renderer.resetPan() } + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/chart/SignalDragDropManager.kt b/Android/app/src/main/java/com/dct/app/presentation/chart/SignalDragDropManager.kt new file mode 100644 index 0000000..54d1907 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/chart/SignalDragDropManager.kt @@ -0,0 +1,110 @@ +package com.dct.app.presentation.chart + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect + +/** + * 드래그 중인 신호 데이터 엔티티 + */ +data class DraggedSignal( + val signalId: String, + val signalName: String, + val colorLong: Long, + val unit: String, + val isCan: Boolean, + val isA2l: Boolean = false, + val min: Double = 0.0, + val max: Double = 100.0, + val rawSignal: com.dct.app.domain.model.mdb.SignalDefinition? = null +) + +/** + * MDI 서브윈도우 간 신호 드래그 앤 드롭(Drag & Drop) 상태 관리 싱글톤 + */ +object SignalDragDropManager { + var isDragging by mutableStateOf(false) + private set + + var draggedSignal by mutableStateOf(null) + private set + + var dragPosition by mutableStateOf(Offset.Zero) + private set + + // 등록된 Graph 윈도우의 루트 화면 좌표 바운즈 (Window ID -> Screen Rect) + private val dropTargets = mutableMapOf() + + fun registerDropTarget(id: String, bounds: Rect) { + dropTargets[id] = bounds + } + + fun unregisterDropTarget(id: String) { + dropTargets.remove(id) + } + + fun clearDropTargets() { + dropTargets.clear() + } + + fun startDrag(signal: DraggedSignal, startPos: Offset) { + draggedSignal = signal + dragPosition = startPos + isDragging = true + } + + fun updateDrag(delta: Offset) { + dragPosition += delta + } + + fun isOverTarget(pos: Offset): Boolean { + if (dropTargets.isEmpty()) return false + return dropTargets.values.any { bounds -> + pos.x >= bounds.left - 20f && + pos.x <= bounds.right + 20f && + pos.y >= bounds.top - 20f && + pos.y <= bounds.bottom + 20f + } + } + + fun findDropTargetId(pos: Offset): String? { + // 1. 내부 정확히 포함된 타겟 우선 (Exact Containment) + for ((id, bounds) in dropTargets.entries.reversed()) { + if (pos.x in bounds.left..bounds.right && pos.y in bounds.top..bounds.bottom) { + return id + } + } + // 2. 근접 여백 영역 (Tolerance) + for ((id, bounds) in dropTargets.entries.reversed()) { + if (pos.x >= bounds.left - 30f && pos.x <= bounds.right + 30f && + pos.y >= bounds.top - 30f && pos.y <= bounds.bottom + 30f) { + return id + } + } + return null + } + + var lastDropTargetId by mutableStateOf(null) + private set + + fun endDrag(onDropOnGraph: (DraggedSignal) -> Unit) { + val signal = draggedSignal + if (signal != null && isDragging) { + val targetId = findDropTargetId(dragPosition) + lastDropTargetId = targetId + val isOverGraph = targetId != null || isOverTarget(dragPosition) + if (isOverGraph) { + onDropOnGraph(signal) + } + } + isDragging = false + draggedSignal = null + } + + fun cancelDrag() { + isDragging = false + draggedSignal = null + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/dashboard/DashboardTileView.kt b/Android/app/src/main/java/com/dct/app/presentation/dashboard/DashboardTileView.kt new file mode 100644 index 0000000..6fa0102 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/dashboard/DashboardTileView.kt @@ -0,0 +1,237 @@ +package com.dct.app.presentation.dashboard + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.core.theme.toComposeColor +import com.dct.app.domain.model.dashboard.TileAlertState +import com.dct.app.domain.model.dashboard.TileWidgetItem + +/** + * 대시보드 단일 타일 컴포넌트 + * + * - JetBrains Mono 기반 고정폭 대형 디지털 수치 렌더링 + * - Normal/Warning/Danger 상태에 따른 고대비 경고 테두리 및 인디케이터 + * - 미니 스파크라인 (단일값/스팬 0 시 0 나누기 방어 처리) + */ +@Composable +fun DashboardTileView( + item: TileWidgetItem, + modifier: Modifier = Modifier +) { + val alertColor = when (item.alertState) { + TileAlertState.NORMAL -> Color(0xFF00875A) + TileAlertState.WARNING -> Color(0xFFFF9900) + TileAlertState.DANGER -> Color(0xFFBA1A1A) + } + + val borderWidth = when (item.alertState) { + TileAlertState.NORMAL -> 1.dp + TileAlertState.WARNING -> 2.dp + TileAlertState.DANGER -> 2.dp + } + + val borderColor = when (item.alertState) { + TileAlertState.NORMAL -> Color(0xFFE2E8F0) + TileAlertState.WARNING -> alertColor + TileAlertState.DANGER -> alertColor + } + + Card( + modifier = modifier + .border(borderWidth, borderColor, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)), + colors = CardDefaults.cardColors( + containerColor = Color.White + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + // 1. Header: 신호명, 컬러 도트, 상태 인디케이터 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(item.color.toComposeColor(), CircleShape) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = item.signalName, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color(0xFF1E293B) + ) + } + + // 경고 상태 도트 + Box( + modifier = Modifier + .size(8.dp) + .background(alertColor, CircleShape) + ) + } + + // 2. Center: 대형 디지털 수치 (JetBrains Mono) 및 단위 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = item.formattedValue, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 26.sp, + color = if (item.alertState == TileAlertState.DANGER) alertColor else Color(0xFF0F172A) + ) + if (item.unit.isNotEmpty()) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = item.unit, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + color = Color(0xFF64748B), + modifier = Modifier.padding(bottom = 3.dp) + ) + } + } + + // 3. Bottom: 미니 스파크라인 파형 + MiniSparkline( + item = item, + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + ) + } + } +} + +/** + * 0 나누기 예외를 완벽 방어하는 미니 스파크라인 캔버스 + */ +@Composable +fun MiniSparkline( + item: TileWidgetItem, + modifier: Modifier = Modifier +) { + val signalColor = item.color.toComposeColor() + + Canvas(modifier = modifier) { + val width = size.width + val height = size.height + val buffer = item.historyBuffer + val count = buffer.size + + if (count < 2) { + // 데이터가 2개 미만이면 중앙 점선 또는 기준선 + drawLine( + color = signalColor.copy(alpha = 0.4f), + start = Offset(0f, height / 2f), + end = Offset(width, height / 2f), + strokeWidth = 1.5f + ) + return@Canvas + } + + var minVal = Double.MAX_VALUE + var maxVal = -Double.MAX_VALUE + + for (i in 0 until count) { + val v = buffer.getValueAt(i) + if (v < minVal) minVal = v + if (v > maxVal) maxVal = v + } + + val span = maxVal - minVal + val path = Path() + + if (span <= 1e-9) { + // [Division by zero Defense]: Min/Max가 동일한 경우 수평 중앙선 렌더링 + val midY = height / 2f + drawLine( + color = signalColor, + start = Offset(0f, midY), + end = Offset(width, midY), + strokeWidth = 2.0f + ) + } else { + val dx = width / (count - 1).toFloat() + for (i in 0 until count) { + val v = buffer.getValueAt(i) + val x = i * dx + val normalizedY = ((v - minVal) / span).toFloat() + val y = height - (normalizedY * (height - 4f)) - 2f + + if (i == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + drawPath( + path = path, + color = signalColor, + style = Stroke( + width = 2.0f, + cap = StrokeCap.Round, + join = StrokeJoin.Round + ) + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/dashboard/TileSparklineDashboard.kt b/Android/app/src/main/java/com/dct/app/presentation/dashboard/TileSparklineDashboard.kt new file mode 100644 index 0000000..cb2286d --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/dashboard/TileSparklineDashboard.kt @@ -0,0 +1,65 @@ +package com.dct.app.presentation.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.dct.app.domain.model.dashboard.TileWidgetItem + +/** + * 대시보드 그리드 모드 (2x2 대형, 3x3 표준, 4x4 집약) + */ +enum class DashboardGridMode(val columns: Int, val maxItems: Int) { + GRID_2X2(2, 4), + GRID_3X3(3, 9), + GRID_4X4(4, 16) +} + +/** + * 반응형 타일 스파크라인 대시보드 컨테이너 + */ +@Composable +fun TileSparklineDashboard( + items: List, + gridMode: DashboardGridMode = DashboardGridMode.GRID_3X3, + modifier: Modifier = Modifier +) { + if (items.isEmpty()) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "모니터링할 신호가 등록되지 않았습니다.", + color = Color(0xFF94A3B8) + ) + } + return + } + + val displayItems = items.take(gridMode.maxItems) + + LazyVerticalGrid( + columns = GridCells.Fixed(gridMode.columns), + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(displayItems, key = { it.signalId }) { item -> + DashboardTileView( + item = item, + modifier = Modifier.fillMaxSize() + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/CommunicationDeviceListDialogs.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/CommunicationDeviceListDialogs.kt new file mode 100644 index 0000000..fdd38aa --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/CommunicationDeviceListDialogs.kt @@ -0,0 +1,978 @@ +package com.dct.app.presentation.dialog + +import android.content.Context +import android.net.wifi.p2p.WifiP2pDevice +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.dct.app.data.communication.BluetoothCommunicationManager +import com.dct.app.data.communication.WifiDirectManager + +/** + * 실제 Android Bluetooth 페어링 및 주변 탐색 장치 목록 다이얼로그 (가짜 더미 데이터 일체 없음) + */ +@Composable +fun BluetoothDeviceListDialog( + currentConnectedDevice: String?, + onSelectDevice: (String) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val manager = remember { BluetoothCommunicationManager(context) } + + val isBluetoothEnabled by manager.isBluetoothEnabled.collectAsState() + val isSearching by manager.isSearching.collectAsState() + val bondedDevices by manager.bondedDevices.collectAsState() + val scannedDevices by manager.scannedDevices.collectAsState() + + DisposableEffect(manager) { + manager.registerReceiver() + manager.refreshBondedDevices() + manager.startDiscovery() + onDispose { + manager.stopDiscovery() + manager.unregisterReceiver() + } + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.White, + shadowElevation = 8.dp, + modifier = Modifier + .width(560.dp) + .height(580.dp) + ) { + Column(modifier = Modifier.fillMaxSize().padding(20.dp)) { + // Header + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(Color(0xFFEBF3FF)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Bluetooth, + contentDescription = null, + tint = Color(0xFF2563EB), + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + text = "Bluetooth 장치 선택", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1E293B) + ) + Text( + text = "페어링된 기기 및 주변 CAN/ECU 동글을 선택하세요.", + fontSize = 11.sp, + color = Color(0xFF64748B) + ) + } + } + + IconButton( + onClick = { + manager.refreshBondedDevices() + manager.startDiscovery() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "새로고침", + tint = Color(0xFF64748B), + modifier = Modifier.size(18.dp) + ) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + HorizontalDivider(color = Color(0xFFE2E8F0)) + Spacer(modifier = Modifier.height(10.dp)) + + // Content (고정된 높이 영역) + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (!isBluetoothEnabled) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.BluetoothDisabled, + contentDescription = null, + tint = Color(0xFF94A3B8), + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Bluetooth가 비활성화되어 있습니다.", + fontSize = 12.sp, + color = Color(0xFF64748B) + ) + Text( + text = "기기 설정에서 Bluetooth를 켜주세요.", + fontSize = 11.sp, + color = Color(0xFF94A3B8) + ) + } + } else if (bondedDevices.isEmpty() && scannedDevices.isEmpty() && !isSearching) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "검색된 Bluetooth 장치가 없습니다.", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF475569) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Android 설정 > Bluetooth에서 CAN/ECU 통신 동글을 먼저 페어링해 주세요.", + fontSize = 11.sp, + color = Color(0xFF94A3B8), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } else { + val listState = rememberLazyListState() + + // 연결된 기기 분리 + val allDevices = (bondedDevices + scannedDevices).distinctBy { it.address } + val connectedDev = allDevices.find { + val name = try { it.name } catch (_: SecurityException) { null } + currentConnectedDevice != null && (name == currentConnectedDevice || it.address == currentConnectedDevice) + } + val remainingBonded = bondedDevices.filter { it.address != connectedDev?.address } + val remainingScanned = scannedDevices.filter { it.address != connectedDev?.address } + + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(end = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (isSearching) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp, color = Color(0xFF2563EB)) + Spacer(modifier = Modifier.width(8.dp)) + Text("주변 Bluetooth 기기를 탐색 중입니다...", fontSize = 11.sp, color = Color(0xFF64748B)) + } + } + } + + // 1. 최상단: 현재 연결된 기기 + if (connectedDev != null) { + item { + Text( + text = "현재 연결된 기기", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF16A34A), + modifier = Modifier.padding(top = 2.dp, bottom = 2.dp) + ) + } + item { + val devName = try { connectedDev.name } catch (_: SecurityException) { null }?.ifBlank { null } ?: "Unnamed Device" + val devAddress = connectedDev.address ?: "" + DeviceListItem( + name = devName, + subText = "MAC: $devAddress (연결됨)", + isSelected = true, + isConnected = true, + onClick = { + onSelectDevice(devName) + onDismiss() + } + ) + } + } + + // 2. 페어링된 기기 + if (remainingBonded.isNotEmpty()) { + item { + Text( + text = "페어링된 기기 (${remainingBonded.size})", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF2563EB), + modifier = Modifier.padding(top = 6.dp, bottom = 2.dp) + ) + } + items(remainingBonded) { device -> + val devName = try { device.name } catch (_: SecurityException) { null }?.ifBlank { null } ?: "Unnamed Device" + val devAddress = device.address ?: "" + + DeviceListItem( + name = devName, + subText = "MAC: $devAddress (페어링됨)", + isSelected = false, + isConnected = false, + onClick = { + manager.connect( + device = device, + onSuccess = { + onSelectDevice(devName) + onDismiss() + }, + onFailure = { + onSelectDevice(devName) + onDismiss() + } + ) + } + ) + } + } + + // 3. 주변 검색된 기기 + if (remainingScanned.isNotEmpty()) { + item { + Text( + text = "주변 검색된 기기 (${remainingScanned.size})", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF10B981), + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp) + ) + } + items(remainingScanned) { device -> + val devName = try { device.name } catch (_: SecurityException) { null }?.ifBlank { null } ?: "Unknown Device" + val devAddress = device.address ?: "" + + DeviceListItem( + name = devName, + subText = "MAC: $devAddress (검색됨)", + isSelected = false, + isConnected = false, + onClick = { + manager.connect( + device = device, + onSuccess = { + onSelectDevice(devName) + onDismiss() + }, + onFailure = { + onSelectDevice(devName) + onDismiss() + } + ) + } + ) + } + } + } + + VerticalListScrollbar( + listState = listState, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(vertical = 4.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + // Actions + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("닫기", color = Color(0xFF64748B), fontSize = 12.sp) + } + } + } + } + } +} + +/** + * 실제 Android Wi-Fi Direct 실시간 탐색 및 SoftAP(직접 IP/Port) 듀얼 모드 연결 다이얼로그 + */ +@Composable +fun WifiDirectDeviceListDialog( + manager: WifiDirectManager? = null, + currentConnectedDevice: String?, + onSelectDevice: (deviceName: String, hostIp: String, port: Int) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val actualManager = manager ?: remember { WifiDirectManager(context) } + + var selectedTab by remember { mutableStateOf(0) } // 0: Wi-Fi Direct, 1: SoftAP (직접 IP) + val inputPreferences = remember(context) { + com.dct.app.data.communication.WifiConnectionPreferences( + context.applicationContext.getSharedPreferences( + com.dct.app.data.communication.WifiConnectionPreferences.FILE_NAME, + android.content.Context.MODE_PRIVATE + ) + ) + } + var manualInput by remember(inputPreferences) { mutableStateOf(inputPreferences.load()) } + + val requiredPermissions = remember { + val list = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + list.add(android.Manifest.permission.NEARBY_WIFI_DEVICES) + } + list.add(android.Manifest.permission.ACCESS_FINE_LOCATION) + list.add(android.Manifest.permission.ACCESS_COARSE_LOCATION) + list + } + + var hasPermissions by remember { + mutableStateOf( + requiredPermissions.all { + androidx.core.content.ContextCompat.checkSelfPermission(context, it) == android.content.pm.PackageManager.PERMISSION_GRANTED + } + ) + } + + val permissionLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + contract = androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + val granted = results.values.all { it } + hasPermissions = granted + if (granted) { + actualManager.discoverPeers() + } + } + + val isP2pEnabled by actualManager.isP2pEnabled.collectAsState() + val isSearching by actualManager.isSearching.collectAsState() + val peers by actualManager.peers.collectAsState() + val lanServers by actualManager.lanServers.collectAsState() + val connectionInfo by actualManager.connectionInfo.collectAsState() + val groupInfo by actualManager.groupInfo.collectAsState() + val lastErrorReason by actualManager.lastErrorReason.collectAsState() + + val isLocationEnabled = remember(context, isP2pEnabled, isSearching) { + val lm = context.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lm?.isLocationEnabled == true + } else { + lm?.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) == true || + lm?.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER) == true + } + } + + val isGroupConnected = connectionInfo?.groupFormed == true + + DisposableEffect(actualManager, hasPermissions) { + actualManager.registerReceiver() + if (hasPermissions) { + actualManager.discoverPeers() + actualManager.refreshConnectionAndPeers() + } else { + permissionLauncher.launch(requiredPermissions.toTypedArray()) + } + onDispose { + actualManager.stopPeerDiscovery() + actualManager.unregisterReceiver() + } + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.White, + shadowElevation = 8.dp, + modifier = Modifier + .width(560.dp) + .height(580.dp) + ) { + Column(modifier = Modifier.fillMaxSize().padding(20.dp)) { + // Header + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(Color(0xFFEBF3FF)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + tint = Color(0xFF2563EB), + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + text = "Wi-Fi 통신 연결", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1E293B) + ) + Text( + text = "DCT 하드웨어와 TCP 소켓 통신을 연결합니다.", + fontSize = 11.sp, + color = Color(0xFF64748B) + ) + } + } + + if (selectedTab == 0) { + IconButton( + onClick = { + actualManager.refreshConnectionAndPeers() + if (hasPermissions) { + actualManager.discoverPeers() + } else { + permissionLauncher.launch(requiredPermissions.toTypedArray()) + } + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "새로고침", + tint = Color(0xFF64748B), + modifier = Modifier.size(18.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 탭 선택 (Wi-Fi Direct vs SoftAP) + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF1F5F9), RoundedCornerShape(6.dp)) + .padding(3.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(4.dp)) + .background(if (selectedTab == 0) Color.White else Color.Transparent) + .clickable { selectedTab = 0 } + .padding(vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Wi-Fi Direct", + fontSize = 12.sp, + fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal, + color = if (selectedTab == 0) Color(0xFF2563EB) else Color(0xFF64748B) + ) + } + + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(4.dp)) + .background(if (selectedTab == 1) Color.White else Color.Transparent) + .clickable { selectedTab = 1 } + .padding(vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "IP 직접 입력", + fontSize = 12.sp, + fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal, + color = if (selectedTab == 1) Color(0xFF2563EB) else Color(0xFF64748B) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = Color(0xFFE2E8F0)) + Spacer(modifier = Modifier.height(10.dp)) + + // Content (고정된 높이 영역) + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (selectedTab == 0) { + // 1. Wi-Fi Direct 모드 + if (!hasPermissions) { + // 권한 미허용 안내 배너 + Surface( + color = Color(0xFFFEF2F2), + shape = RoundedCornerShape(6.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFFECACA)), + modifier = Modifier.fillMaxWidth().align(Alignment.Center).padding(vertical = 12.dp) + ) { + Column( + modifier = Modifier.padding(14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Wi-Fi Direct 탐색을 위해 권한이 필요합니다.", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFDC2626) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Android 근처 기기(Wi-Fi) 및 위치 권한을 허용해 주세요.", + fontSize = 11.sp, + color = Color(0xFF7F1D1D) + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { permissionLauncher.launch(requiredPermissions.toTypedArray()) }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) + ) { + Text("권한 허용하기", fontSize = 11.sp, color = Color.White) + } + } + } + } else if (!isLocationEnabled) { + // 위치(GPS) 서비스 꺼짐 안내 배너 + Surface( + color = Color(0xFFFFFBEB), + shape = RoundedCornerShape(6.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFFDE68A)), + modifier = Modifier.fillMaxWidth().align(Alignment.Center).padding(vertical = 12.dp) + ) { + Column( + modifier = Modifier.padding(14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "태블릿의 위치(GPS) 서비스가 꺼져 있습니다.", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFB45309) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Android 보안 정책상 위치 서비스가 켜져 있어야 주변 Wi-Fi Direct 기기를 검색할 수 있습니다.", + fontSize = 11.sp, + color = Color(0xFF92400E), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + try { + context.startActivity( + android.content.Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply { + flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } catch (_: Exception) {} + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD97706)) + ) { + Text("위치(GPS) 설정 열기", fontSize = 11.sp, color = Color.White) + } + } + } + } else if (peers.isEmpty() && lanServers.isEmpty() && !isGroupConnected) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isSearching) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp, color = Color(0xFF2563EB)) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "주변 Wi-Fi Direct 기기를 탐색 중입니다...", + fontSize = 12.sp, + color = Color(0xFF475569) + ) + } else { + Icon( + imageVector = Icons.Default.WifiOff, + contentDescription = null, + tint = Color(0xFF94A3B8), + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "탐색된 Wi-Fi Direct 장치가 없습니다.", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF475569) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "1. 태블릿의 Wi-Fi 및 위치(GPS)가 켜져 있는지 확인해 주세요.\n2. 상대 기기의 Wi-Fi Direct가 켜져 있는지 확인 후 상단 [새로고침]을 눌러주세요.\n3. 자체 핫스팟 모드인 경우 [IP 직접 입력] 탭을 이용해 주세요.", + fontSize = 11.sp, + color = Color(0xFF94A3B8), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } + } else { + val listState = rememberLazyListState() + + val groupClients = groupInfo?.clientList?.toList() ?: emptyList() + val groupOwner = groupInfo?.owner + + // 연결된 기기 분리 및 최상단 정렬 (그룹 형성 또는 상태 기준) + val connectedPeer = peers.find { peer -> + val isMatchedByGroup = isGroupConnected && ( + (groupOwner != null && (peer.deviceAddress == groupOwner.deviceAddress || peer.deviceName == groupOwner.deviceName)) || + groupClients.any { it.deviceAddress == peer.deviceAddress || it.deviceName == peer.deviceName } + ) + val isMatchedByName = currentConnectedDevice != null && (peer.deviceName == currentConnectedDevice || peer.deviceAddress == currentConnectedDevice) + val isMatchedByStatus = peer.status == WifiP2pDevice.CONNECTED + isMatchedByGroup || isMatchedByName || isMatchedByStatus + } + val remainingPeers = peers.filter { it.deviceAddress != connectedPeer?.deviceAddress } + + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(end = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (isSearching) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp, color = Color(0xFF2563EB)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Wi-Fi Direct 탐색 중...", fontSize = 11.sp, color = Color(0xFF64748B)) + } + } + } + + // 1. 최상단: 현재 연결된 기기 + if (connectedPeer != null || isGroupConnected) { + val devName = connectedPeer?.deviceName?.ifBlank { null } + ?: (if (connectionInfo?.isGroupOwner == true) groupClients.firstOrNull()?.deviceName else groupOwner?.deviceName)?.ifBlank { null } + ?: "Wi-Fi Direct Device" + val devAddress = connectedPeer?.deviceAddress ?: groupOwner?.deviceAddress ?: "" + val hostIp = connectionInfo?.groupOwnerAddress?.hostAddress ?: "192.168.49.1" + + item { + Text( + text = "현재 연결된 기기", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF16A34A), + modifier = Modifier.padding(top = 2.dp, bottom = 2.dp) + ) + } + item { + DeviceListItem( + name = devName, + subText = "IP: $hostIp | MAC: $devAddress (연결됨)", + isSelected = true, + isConnected = true, + onClick = { + onSelectDevice(devName, hostIp, 6000) + onDismiss() + } + ) + } + } + + // 2. 탐색된 PC 테스트 서버 (LAN/Wi-Fi 자동 탐색) + if (lanServers.isNotEmpty()) { + item { + Text( + text = "탐색된 PC 테스트 서버 (${lanServers.size})", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF16A34A), + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp) + ) + } + items(lanServers) { lanDev -> + DeviceListItem( + name = lanDev.deviceName, + subText = "IP: ${lanDev.hostIp}:${lanDev.port} | 상태: 연결 가능 (클릭 시 즉시 연결)", + isSelected = false, + isConnected = false, + onClick = { + onSelectDevice(lanDev.deviceName, lanDev.hostIp, lanDev.port) + onDismiss() + } + ) + } + } + + // 3. 주변 탐색된 Wi-Fi Direct 기기 + if (remainingPeers.isNotEmpty()) { + item { + Text( + text = "주변 탐색된 기기 (${remainingPeers.size})", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF2563EB), + modifier = Modifier.padding(top = 6.dp, bottom = 2.dp) + ) + } + items(remainingPeers) { device -> + val devName = device.deviceName.ifBlank { "Wi-Fi Direct Device" } + val devAddress = device.deviceAddress ?: "" + val isThisDeviceConnected = device.status == WifiP2pDevice.CONNECTED || (isGroupConnected && (device.deviceAddress == groupOwner?.deviceAddress || groupClients.any { it.deviceAddress == device.deviceAddress })) + + val statusLabel = when { + isThisDeviceConnected -> "연결됨" + device.status == WifiP2pDevice.INVITED -> "초대 중" + device.status == WifiP2pDevice.FAILED -> "실패" + device.status == WifiP2pDevice.AVAILABLE -> "연결 가능" + device.status == WifiP2pDevice.UNAVAILABLE -> "사용 불가" + else -> "대기" + } + + DeviceListItem( + name = devName, + subText = "MAC: $devAddress | 상태: $statusLabel", + isSelected = isThisDeviceConnected, + isConnected = isThisDeviceConnected, + onClick = { + actualManager.connect( + device = device, + onSuccessWithIp = { hostIp -> + onSelectDevice(devName, hostIp, 6000) + onDismiss() + }, + onFailure = { + val fallbackIp = connectionInfo?.groupOwnerAddress?.hostAddress ?: "192.168.4.1" + onSelectDevice(devName, fallbackIp, 6000) + onDismiss() + } + ) + } + ) + } + } + } + + VerticalListScrollbar( + listState = listState, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(vertical = 4.dp) + ) + } + } + } + } else { + WifiManualConnectionForm( + input = manualInput, + onInputChange = { + manualInput = it + inputPreferences.save(it) + }, + servers = lanServers, + onOpenWifiSettings = { + try { + context.startActivity(android.content.Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)) + } catch (_: Exception) {} + }, + onConnect = { ip, port -> + onSelectDevice("Wi-Fi ($ip:$port)", ip, port) + onDismiss() + } + ) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + // Actions + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("닫기", color = Color(0xFF64748B), fontSize = 12.sp) + } + } + } + } + } +} + +@Composable +private fun DeviceListItem( + name: String, + subText: String, + isSelected: Boolean, + isConnected: Boolean = false, + onClick: () -> Unit +) { + val borderColor = when { + isConnected -> Color(0xFF86EFAC) // Soft Green + isSelected -> Color(0xFF93C5FD) // Blue + else -> Color(0xFFE2E8F0) + } + val bgColor = when { + isConnected -> Color(0xFFF0FDF4) + isSelected -> Color(0xFFEFF6FF) + else -> Color(0xFFF8FAFC) + } + + Surface( + color = bgColor, + shape = RoundedCornerShape(6.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, borderColor), + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = name, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = when { + isConnected -> Color(0xFF15803D) + isSelected -> Color(0xFF1D4ED8) + else -> Color(0xFF334155) + } + ) + if (isConnected) { + Spacer(modifier = Modifier.width(6.dp)) + Surface( + color = Color(0xFFDCFCE7), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "연결됨", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF16A34A), + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp) + ) + } + } + } + Text( + text = subText, + fontSize = 10.sp, + color = Color(0xFF94A3B8) + ) + } + + if (isConnected || isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Connected", + tint = if (isConnected) Color(0xFF16A34A) else Color(0xFF2563EB), + modifier = Modifier.size(18.dp) + ) + } + } + } +} + +/** + * LazyColumn 스크롤 가능 여부 및 현재 위치를 시각적으로 보여주는 커스텀 수직 스크롤바 + */ +@Composable +private fun VerticalListScrollbar( + listState: androidx.compose.foundation.lazy.LazyListState, + modifier: Modifier = Modifier +) { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val visibleItems = layoutInfo.visibleItemsInfo.size + val firstIndex = listState.firstVisibleItemIndex + val firstItemOffset = listState.firstVisibleItemScrollOffset + + androidx.compose.foundation.Canvas(modifier = modifier.fillMaxHeight().width(5.dp)) { + // Track (항상 스크롤바 트랙 노출) + drawRoundRect( + color = Color(0xFFE2E8F0), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(6f, 6f) + ) + // Thumb (항상 스크롤 썸 노출) + val ratio = if (totalItems > 0) (visibleItems.toFloat() / totalItems.toFloat()).coerceIn(0.15f, 1f) else 1f + val thumbHeight = (size.height * ratio).coerceIn(28f, size.height) + val maxScrollable = (totalItems - visibleItems).coerceAtLeast(1) + val progress = if (totalItems > visibleItems && maxScrollable > 0) { + val approximateOffset = firstIndex.toFloat() + (firstItemOffset.toFloat() / 150f).coerceIn(0f, 1f) + (approximateOffset / maxScrollable.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } + val scrollOffset = (size.height - thumbHeight) * progress + drawRoundRect( + color = Color(0xFF94A3B8), + topLeft = androidx.compose.ui.geometry.Offset(0f, scrollOffset), + size = androidx.compose.ui.geometry.Size(size.width, thumbHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(6f, 6f) + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/ProjectFileDialogs.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/ProjectFileDialogs.kt new file mode 100644 index 0000000..5c106e2 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/ProjectFileDialogs.kt @@ -0,0 +1,497 @@ +package com.dct.app.presentation.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Documents/DCT Work/Configuration 폴더 기반 프로젝트(.dpc) 열기 다이얼로그 + */ +@Composable +fun OpenProjectDialog( + projectFiles: List, + onSelectFile: (File) -> Unit, + onBrowseOther: () -> Unit, + onDismiss: () -> Unit +) { + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)) + // Keep outside-click dismissal without a full-screen stylus hover state layer. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ), + contentAlignment = Alignment.Center + ) { + Surface( + color = Color.White, + shape = RoundedCornerShape(8.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFCBD5E1)), + shadowElevation = 8.dp, + modifier = Modifier + .widthIn(max = 560.dp) + .fillMaxWidth(0.85f) + .heightIn(max = 480.dp) + .clickable(enabled = false) {} + ) { + Column(modifier = Modifier.fillMaxSize()) { + // 1. 헤더 (타이틀 + X 닫기 버튼) + Surface( + color = Color.White, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .border(width = 1.dp, color = Color(0xFFE2E8F0)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + tint = Color(0xFF2563EB), + modifier = Modifier.size(20.dp) + ) + Text( + text = "Open Project file", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1E293B) + ) + } + IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = Color(0xFF64748B), + modifier = Modifier.size(18.dp) + ) + } + } + } + + // 2. 디렉터리 경로 안내 바 + Surface( + color = Color(0xFFF8FAFC), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(4.dp), + border = androidx.compose.foundation.BorderStroke(0.5.dp, Color(0xFFE2E8F0)) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "위치: Documents/DCT Work/Configuration", + fontSize = 11.sp, + color = Color(0xFF64748B), + fontFamily = FontFamily.Monospace + ) + } + } + + // 3. 프로젝트 파일 목록 + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + if (projectFiles.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Description, + contentDescription = null, + tint = Color(0xFF94A3B8), + modifier = Modifier.size(36.dp) + ) + Text( + text = "Configuration 폴더에 저장된 프로젝트가 없습니다.", + fontSize = 13.sp, + color = Color(0xFF64748B) + ) + } + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxSize() + ) { + items(projectFiles) { file -> + val dateStr = remember(file) { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + sdf.format(Date(file.lastModified())) + } + val sizeStr = remember(file) { + val kb = file.length() / 1024.0 + String.format(Locale.US, "%.1f KB", kb) + } + Surface( + color = Color.White, + shape = RoundedCornerShape(4.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFE2E8F0)), + modifier = Modifier + .fillMaxWidth() + .clickable { onSelectFile(file) } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = Icons.Default.InsertDriveFile, + contentDescription = null, + tint = Color(0xFF2563EB), + modifier = Modifier.size(22.dp) + ) + Column { + Text( + text = file.name, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF1E293B) + ) + Text( + text = "수정일: $dateStr | 크기: $sizeStr", + fontSize = 11.sp, + color = Color(0xFF64748B) + ) + } + } + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = Color(0xFF94A3B8), + modifier = Modifier.size(18.dp) + ) + } + } + } + } + } + } + + HorizontalDivider(color = Color(0xFFE2E8F0)) + + // 4. 하단 액션 버튼 바 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = onBrowseOther, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFF2563EB)), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF3B82F6)) + ) { + Icon(imageVector = Icons.Default.FolderOpen, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("다른 위치에서 파일 찾기", fontSize = 12.sp) + } + + Button( + onClick = onDismiss, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF1F5F9), contentColor = Color(0xFF334155)), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFCBD5E1)) + ) { + Text("닫기", fontSize = 12.sp) + } + } + } + } + } + } +} + +/** + * MDB, A2L, DBC, DPC 특정 확장자 전용 파일 선택 다이얼로그 (일치하지 않는 확장자는 비활성화/제외) + */ +@Composable +fun SelectSpecificFileDialog( + title: String, + subFolderName: String, + extensionName: String, + files: List, + onSelectFile: (File) -> Unit, + onBrowseOther: () -> Unit, + onDismiss: () -> Unit +) { + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .widthIn(max = 580.dp) + .fillMaxWidth(0.92f) + .heightIn(min = 340.dp, max = 500.dp) + .padding(16.dp), + shape = RoundedCornerShape(8.dp), + color = Color(0xFFF8FAFC), + shadowElevation = 12.dp + ) { + Column(modifier = Modifier.fillMaxSize()) { + // 1. 헤더 + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + tint = Color(0xFF2563EB), + modifier = Modifier.size(20.dp) + ) + Text( + text = title, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1E293B) + ) + } + IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = Color(0xFF64748B), + modifier = Modifier.size(18.dp) + ) + } + } + + HorizontalDivider(color = Color(0xFFE2E8F0)) + + // 2. 경로 안내 + Surface( + color = Color(0xFFF1F5F9), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(4.dp), + border = androidx.compose.foundation.BorderStroke(0.5.dp, Color(0xFFE2E8F0)) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = null, + tint = Color(0xFF64748B), + modifier = Modifier.size(14.dp) + ) + Text( + text = "위치: Documents/DCT Work/$subFolderName/ (*$extensionName 만 표시)", + fontSize = 11.sp, + color = Color(0xFF64748B), + fontFamily = FontFamily.Monospace + ) + } + } + + // 3. 파일 목록 (해당 확장자 파일만 표시) + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + if (files.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Description, + contentDescription = null, + tint = Color(0xFF94A3B8), + modifier = Modifier.size(36.dp) + ) + Text( + text = "$subFolderName 폴더에 *$extensionName 파일이 없습니다.", + fontSize = 13.sp, + color = Color(0xFF64748B) + ) + Text( + text = "아래 '다른 위치에서 파일 찾기'를 눌러 외부 경로에서 선택할 수 있습니다.", + fontSize = 11.sp, + color = Color(0xFF94A3B8) + ) + } + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxSize() + ) { + items(files) { file -> + val dateStr = remember(file) { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + sdf.format(Date(file.lastModified())) + } + val sizeStr = remember(file) { + val kb = file.length() / 1024.0 + if (kb >= 1024.0) { + String.format(Locale.US, "%.2f MB", kb / 1024.0) + } else { + String.format(Locale.US, "%.1f KB", kb) + } + } + Surface( + color = Color.White, + shape = RoundedCornerShape(4.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFE2E8F0)), + modifier = Modifier + .fillMaxWidth() + .clickable { onSelectFile(file) } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = Icons.Default.InsertDriveFile, + contentDescription = null, + tint = Color(0xFF2563EB), + modifier = Modifier.size(22.dp) + ) + Column { + Text( + text = file.name, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF1E293B) + ) + Text( + text = "수정일: $dateStr | 크기: $sizeStr", + fontSize = 11.sp, + color = Color(0xFF64748B) + ) + } + } + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = Color(0xFF94A3B8), + modifier = Modifier.size(18.dp) + ) + } + } + } + } + } + } + + HorizontalDivider(color = Color(0xFFE2E8F0)) + + // 4. 하단 액션 버튼 바 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = onBrowseOther, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFF2563EB)), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF3B82F6)) + ) { + Icon(imageVector = Icons.Default.FolderOpen, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("다른 위치에서 파일 찾기", fontSize = 12.sp) + } + + Button( + onClick = onDismiss, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF1F5F9), contentColor = Color(0xFF334155)), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFCBD5E1)) + ) { + Text("닫기", fontSize = 12.sp) + } + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiManualConnectionForm.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiManualConnectionForm.kt new file mode 100644 index 0000000..6be33d8 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiManualConnectionForm.kt @@ -0,0 +1,65 @@ +package com.dct.app.presentation.dialog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.data.communication.DiscoveredLanServer +import com.dct.app.data.communication.WifiConnectionInput + +@Composable +internal fun WifiManualConnectionForm( + input: WifiConnectionInput, + onInputChange: (WifiConnectionInput) -> Unit, + servers: List, + onOpenWifiSettings: () -> Unit, + onConnect: (String, Int) -> Unit +) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("DCT 하드웨어 IP 및 포트 번호", fontSize = 12.sp, color = Color(0xFF334155)) + TextButton(onClick = onOpenWifiSettings) { Text("Wi-Fi 설정 열기", fontSize = 11.sp) } + OutlinedTextField( + value = input.ip, onValueChange = { onInputChange(input.copy(ip = it)) }, + label = { Text("IP 주소 (IPv4)", fontSize = 11.sp) }, singleLine = true, + isError = input.ip.isNotBlank() && !input.isIpValid, + modifier = Modifier.fillMaxWidth().testTag("wifi:manual-ip") + ) + if (input.ip.isNotBlank() && !input.isIpValid) Text("올바른 IPv4 주소를 입력하세요.", color = MaterialTheme.colorScheme.error, fontSize = 11.sp) + OutlinedTextField( + value = input.port, onValueChange = { onInputChange(input.copy(port = it)) }, + label = { Text("TCP 포트 (1~65535)", fontSize = 11.sp) }, singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = input.port.isNotBlank() && input.portNumber == null, + modifier = Modifier.fillMaxWidth().testTag("wifi:manual-port") + ) + if (input.port.isNotBlank() && input.portNumber == null) Text("포트는 1~65535 사이의 정수여야 합니다.", color = MaterialTheme.colorScheme.error, fontSize = 11.sp) + servers.firstOrNull()?.let { server -> + Surface(color = Color(0xFFF0FDF4), shape = RoundedCornerShape(4.dp), + modifier = Modifier.fillMaxWidth().clickable { + onInputChange(WifiConnectionInput(server.hostIp, server.port.toString())) + }) { + Column(Modifier.padding(10.dp)) { + Text("탐색된 PC: ${server.deviceName} (${server.hostIp})", fontSize = 11.sp, color = Color(0xFF15803D)) + Text("클릭하여 IP와 포트 적용", fontSize = 10.sp) + } + } + } + Button(onClick = { + val port = input.portNumber + if (input.isIpValid && port != null) onConnect(input.ip.trim(), port) + }, enabled = input.canConnect, modifier = Modifier.fillMaxWidth().testTag("wifi:manual-connect")) { + Text("소켓 연결 시작", fontSize = 13.sp) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/DctTabletApp.kt b/Android/app/src/main/java/com/dct/app/presentation/main/DctTabletApp.kt new file mode 100644 index 0000000..e753409 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/main/DctTabletApp.kt @@ -0,0 +1,64 @@ +package com.dct.app.presentation.main + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.dct.app.core.common.DefaultDispatcherProvider +import com.dct.app.core.common.EventBus +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.repository.MdbRepositoryImpl +import com.dct.app.data.repository.PacketRepositoryImpl +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import com.dct.app.presentation.calibration.CalibrationViewModel +import com.dct.app.presentation.mdi.MdiViewModel +import com.dct.app.presentation.monitor.DiagnosticDataMonitorScreen +import com.dct.app.presentation.signaltree.SignalTreeViewModel + +/** + * DCT 태블릿 앱 엔트리포인트 + * 메인 데이터 모니터 화면으로 직접 진입 + */ +@Composable +fun DctTabletApp( + viewModel: MainViewModel? = null, + modifier: Modifier = Modifier +) { + val mainViewModel = viewModel ?: remember { + val dispatchers = DefaultDispatcherProvider() + val eventBus = EventBus() + val mdbRepo = MdbRepositoryImpl(dispatchers) + val chartRenderer = ChartBatchRenderer(dispatchers) + val treeVm = SignalTreeViewModel(dispatchers) + val mdiVm = MdiViewModel(dispatchers) + val calibVm = CalibrationViewModel(mdbRepo, dispatchers) + val wifiManager = WifiSocketManager() + val packetRepo = PacketRepositoryImpl( + wifiSocketManager = wifiManager, + ioDispatcher = dispatchers.io + ) + + MainViewModel( + mdbRepository = mdbRepo, + chartRenderer = chartRenderer, + treeViewModel = treeVm, + mdiViewModel = mdiVm, + calibrationViewModel = calibVm, + eventBus = eventBus, + dispatchers = dispatchers, + wifiSocketManager = wifiManager, + packetRepository = packetRepo + ) + } + + Surface( + modifier = modifier.fillMaxSize(), + color = Color(0xFFF8F9FB) + ) { + DiagnosticDataMonitorScreen( + viewModel = mainViewModel, + modifier = Modifier.fillMaxSize() + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt b/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt new file mode 100644 index 0000000..2f0894e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt @@ -0,0 +1,103 @@ +package com.dct.app.presentation.main + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.dct.app.core.theme.DctTheme +import com.dct.app.core.theme.DctThemeTokens + +/** + * DCT 안드로이드 태블릿 메인 액티비티 + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // 모든 파일 관리 권한 및 통신(Wi-Fi Direct, Bluetooth, 위치) 권한 확인 및 요청 + checkAndRequestStoragePermission() + checkAndRequestCommunicationPermissions() + + // 앱 시작 시 Documents/DCT Work 및 8대 서브폴더 자동 생성 + try { + com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(this) + } catch (_: Exception) {} + + setContent { + DctTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = DctThemeTokens.colors.background + ) { + DctTabletApp() + } + } + } + } + + override fun onResume() { + super.onResume() + // 권한 부여 후 복귀 시 8대 서브폴더 즉시 재생성 보장 + try { + com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(this) + } catch (_: Exception) {} + } + + private fun checkAndRequestCommunicationPermissions() { + val permissions = mutableListOf() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(android.Manifest.permission.NEARBY_WIFI_DEVICES) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissions.add(android.Manifest.permission.BLUETOOTH_SCAN) + permissions.add(android.Manifest.permission.BLUETOOTH_CONNECT) + } + permissions.add(android.Manifest.permission.ACCESS_FINE_LOCATION) + permissions.add(android.Manifest.permission.ACCESS_COARSE_LOCATION) + + val needed = permissions.filter { + checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED + } + if (needed.isNotEmpty()) { + requestPermissions(needed.toTypedArray(), 1002) + } + } + + private fun checkAndRequestStoragePermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (!Environment.isExternalStorageManager()) { + try { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = Uri.parse("package:$packageName") + } + startActivity(intent) + } catch (_: Exception) { + try { + val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) + startActivity(intent) + } catch (_: Exception) {} + } + } + } else { + val permissions = arrayOf( + android.Manifest.permission.READ_EXTERNAL_STORAGE, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + val needed = permissions.filter { + checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED + } + if (needed.isNotEmpty()) { + requestPermissions(needed.toTypedArray(), 1001) + } + } + } +} + diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt new file mode 100644 index 0000000..f091f63 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt @@ -0,0 +1,3102 @@ +package com.dct.app.presentation.main + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import com.dct.app.core.common.AppEvent +import com.dct.app.core.common.DctResult +import com.dct.app.core.common.DispatcherProvider +import com.dct.app.core.common.EventBus +import com.dct.app.data.parser.A2lParser.A2lCharacteristic +import com.dct.app.data.parser.A2lParser.A2lCharacteristicArray +import com.dct.app.domain.model.WorkspacePageState +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.ChartSignalSeries +import com.dct.app.domain.model.chart.InterpolationMode +import com.dct.app.domain.model.dashboard.TileAlertState +import com.dct.app.domain.model.dashboard.TileWidgetItem +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.repository.MdbRepository +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import com.dct.app.presentation.calibration.CalibrationViewModel +import com.dct.app.presentation.mdi.MdiViewModel +import com.dct.app.presentation.mdi.MdiWindowType +import com.dct.app.presentation.signaltree.AddToChartRequest +import com.dct.app.presentation.signaltree.SignalTreeViewModel +import com.dct.app.presentation.signaltree.TreeItemSelection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.withLock +import java.io.ByteArrayOutputStream +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +enum class CommunicationType { + NONE, + BLUETOOTH, + WIFI +} + +/** + * 메인 애플리케이션 UI 상태 + */ +data class MainUiState( + val mdbDatabase: MdbDatabase? = null, + val mdbFileName: String? = null, + val canDbcFileName: String? = null, + val a2lFileName: String? = null, + val projectFileName: String? = null, + val isMdbLoading: Boolean = false, + val isMeasuring: Boolean = false, + val measurementFrequencyHz: Int = 30, + val packetCount: Long = 0L, + val dashboardTiles: List = emptyList(), + val currentSelection: TreeItemSelection? = null, + val isConnected: Boolean = false, + val communicationType: CommunicationType = CommunicationType.NONE, + val connectedDeviceName: String? = null, + val statusMessage: String? = null, + val graphInterpolationMode: InterpolationMode = InterpolationMode.LINEAR +) + +/** + * M5 Stitch 3-Pane 메인 ViewModel + */ +class MainViewModel( + val mdbRepository: MdbRepository, + val chartRenderer: ChartBatchRenderer, + val treeViewModel: SignalTreeViewModel, + val mdiViewModel: MdiViewModel, + val calibrationViewModel: CalibrationViewModel, + val eventBus: EventBus, + val dispatchers: DispatcherProvider, + val layoutState: ThreePaneLayoutState = ThreePaneLayoutState(), + val wifiSocketManager: com.dct.app.data.communication.WifiSocketManager = com.dct.app.data.communication.WifiSocketManager(), + val packetRepository: com.dct.app.domain.repository.PacketRepository = com.dct.app.data.repository.PacketRepositoryImpl( + wifiSocketManager = wifiSocketManager + ) +) : ViewModel() { + + val scope: CoroutineScope = CoroutineScope(dispatchers.mainImmediate + SupervisorJob()) + private val calibrationMutex = kotlinx.coroutines.sync.Mutex() + private var calibrationJob: Job? = null + private val sessionMutex = kotlinx.coroutines.sync.Mutex() + private val connectionGeneration = java.util.concurrent.atomic.AtomicLong() + @Volatile private var configurationRequired = false + + private val _isCalibrationBusy = MutableStateFlow(false) + val isCalibrationBusy: StateFlow = _isCalibrationBusy.asStateFlow() + + private fun runCalibration(label: String, onComplete: ((String?) -> Unit)? = null, action: suspend () -> Unit) { + if (!calibrationMutex.tryLock()) { + _uiState.update { it.copy(statusMessage = "이전 보정 요청이 진행 중입니다.") } + onComplete?.invoke("이전 보정 요청이 진행 중입니다.") + return + } + _isCalibrationBusy.value = true + val revision = _workspaceRevision.value + calibrationJob = scope.launch { + try { + require(packetRepository.connectionState.value == DeviceConnectionState.READY) { + "보정은 Wi-Fi 장비 연결 후 계측을 정지한 상태에서 실행하세요." + } + _uiState.update { it.copy(statusMessage = "$label 요청 중") } + action() + if (_workspaceRevision.value == revision) { + _uiState.update { it.copy(statusMessage = "$label 완료") } + onComplete?.invoke(null) + } + } catch (e: kotlinx.coroutines.CancellationException) { + if (_workspaceRevision.value == revision) onComplete?.invoke("요청이 취소되었습니다.") + throw e + } catch (e: Exception) { + if (_workspaceRevision.value == revision) { + _uiState.update { it.copy(statusMessage = "$label 실패: ${e.message}") } + onComplete?.invoke(e.message ?: "장비 요청에 실패했습니다.") + } + } finally { + calibrationMutex.unlock() + _isCalibrationBusy.value = false + } + } + } + + private fun DctResult.calibrationValue(): T = when (this) { + is DctResult.Success -> data + is DctResult.Failure -> throw IllegalStateException(error.message, cause) + DctResult.Loading -> throw IllegalStateException("요청이 완료되지 않았습니다.") + } + + private val _uiState = MutableStateFlow(MainUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _canSignals = MutableStateFlow>(emptyList()) + val canSignals: StateFlow> = _canSignals.asStateFlow() + + init { + // 실제 DCT 하드웨어 패킷 스트림 수집 + scope.launch(dispatchers.default) { + packetRepository.packetStream.collect { packet -> + try { + val nowSec = System.currentTimeMillis() / 1000.0 + processIncomingPacket(packet, nowSec) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + // A bad packet/logging error must not terminate the long-lived subscription. + _uiState.update { it.copy(statusMessage = "수신 패킷 처리 실패 (다음 패킷 수신 계속): ${e.message}") } + } + } + } + scope.launch(dispatchers.mainImmediate) { + packetRepository.connectionState.collect { state -> + val transportReady = state == DeviceConnectionState.READY || state == DeviceConnectionState.MEASURING + val isDisconnectedOrError = state == DeviceConnectionState.ERROR || state == DeviceConnectionState.DISCONNECTED + if (isDisconnectedOrError) { + if (_uiState.value.isMeasuring) { + val generation = connectionGeneration.get() + scope.launch(dispatchers.io) { + sessionMutex.withLock { + if (generation == connectionGeneration.get() && + packetRepository.connectionState.value in setOf(DeviceConnectionState.ERROR, DeviceConnectionState.DISCONNECTED)) { + finishMeasurementSession() + } + } + } + } + } + _uiState.update { + it.copy( + isConnected = transportReady, + isMeasuring = (state == DeviceConnectionState.MEASURING), + communicationType = if (isDisconnectedOrError) CommunicationType.NONE else it.communicationType, + connectedDeviceName = if (isDisconnectedOrError) null else it.connectedDeviceName, + statusMessage = when (state) { + DeviceConnectionState.ERROR -> "Wi-Fi 장비 연결이 종료되었습니다." + DeviceConnectionState.DISCONNECTED -> if (it.communicationType == CommunicationType.NONE) it.statusMessage else "장비 연결이 해제되었습니다." + else -> it.statusMessage + } + ) + } + } + } + } + + private val _a2lSignals = MutableStateFlow>(emptyList()) + val a2lSignals: StateFlow> = _a2lSignals.asStateFlow() + + private val _a2lCharacteristics = MutableStateFlow>(emptyList()) + val a2lCharacteristics: StateFlow> = _a2lCharacteristics.asStateFlow() + + private val _a2lCharacteristicsArray = MutableStateFlow>(emptyList()) + val a2lCharacteristicsArray: StateFlow> = _a2lCharacteristicsArray.asStateFlow() + + private val xcpDaqDecoder = com.dct.app.core.engine.XcpDaqStreamDecoder() + private var currentXcpIfData: com.dct.app.data.parser.A2lParser.XcpIfData? = null + + private val _currentProject = MutableStateFlow(null) + val currentProject: StateFlow = _currentProject.asStateFlow() + + private val seedKeyProvider: com.dct.app.data.maintenance.SeedKeyProvider = + com.dct.app.data.maintenance.NativeSeedKeyProvider.load().getOrElse { + com.dct.app.data.maintenance.UnavailableSeedKeyProvider( + "Android용 libHKMC_ASK.so/JNI 구현이 없어 Seed-Key 인증을 수행할 수 없습니다." + ) + } + val maintenanceService = com.dct.app.data.maintenance.MaintenanceService(packetRepository) + + init { + maintenanceService.startSeedListener(scope) + scope.launch(dispatchers.mainImmediate) { + currentProject.collect { project -> + val ask = project?.askInfo ?: com.dct.app.domain.model.project.DpcAskInfo() + val addressText = ask.firmwareAddress.trim().removePrefix("0x").removePrefix("0X") + val address = addressText.toLongOrNull(16)?.takeIf { it in 0..0xFFFF_FFFFL } ?: 0L + maintenanceService.configureSeedKey( + seedKeyProvider, + com.dct.app.domain.model.maintenance.SeedKeyConfiguration( + firmwareAddress = address, + stMin = ask.stMin.coerceIn(0, 0xFF), + dependsOnRom = ask.dependsOnRom + ) + ) + } + } + } + + private val _formulas = MutableStateFlow>(emptyList()) + val formulas: StateFlow> = _formulas.asStateFlow() + + private val _sensorModules = MutableStateFlow>( + com.dct.app.domain.model.project.createDefaultSensorModules() + ) + val sensorModules: StateFlow> = _sensorModules.asStateFlow() + + private val _liveTelemetryValues = MutableStateFlow>(emptyMap()) + val liveTelemetryValues: StateFlow> = _liveTelemetryValues.asStateFlow() + + private val initialPages = listOf( + WorkspacePageState(id = "page_1", title = "Page 1") + ) + private val _pages = MutableStateFlow>(initialPages) + val pages: StateFlow> = _pages.asStateFlow() + + private val _activePageId = MutableStateFlow("page_1") + val activePageId: StateFlow = _activePageId.asStateFlow() + + private var chartRenderLoopJob: Job? = null + + init { + chartRenderer.pause() + chartRenderLoopJob = chartRenderer.start(scope) + // 신호 트리 이벤트 구독 (축 바인딩 및 선택) + observeTreeEvents() + } + + /** + * 신호 트리 및 인스펙터 이벤트 관찰 + */ + private fun observeTreeEvents() { + // 1. 차트 추가 이벤트 + scope.launch(dispatchers.mainImmediate) { + treeViewModel.addToChartEvents.collect { req -> + handleAddToChart(req) + } + } + + // 2. 신호 선택 이벤트 + scope.launch(dispatchers.mainImmediate) { + treeViewModel.uiState.collect { treeState -> + val selection = treeState.selectedSelection + _uiState.update { it.copy(currentSelection = selection) } + when (selection) { + is TreeItemSelection.ConstantSelected -> { + calibrationViewModel.selectConstant(selection.constant) + } + is TreeItemSelection.MapSelected -> { + calibrationViewModel.selectCompensationMap(selection.map) + } + is TreeItemSelection.SignalSelected -> { + calibrationViewModel.selectSignal(selection.signal) + } + else -> {} + } + } + } + + } + + private val _windowChartRenderers = java.util.concurrent.ConcurrentHashMap() + private var projectLoadJob: Job? = null + @Volatile private var isProjectLoading = false + private val _workspaceRevision = MutableStateFlow(0L) + val workspaceRevision: StateFlow = _workspaceRevision.asStateFlow() + private val viewportReaders = mutableMapOf Pair>() + + fun registerPageViewport(pageId: String, revision: Long, reader: () -> Pair): () -> Unit { + if (revision != _workspaceRevision.value) return {} + viewportReaders[pageId] = reader + return { + if (revision == _workspaceRevision.value && viewportReaders[pageId] === reader) { + val (x, y) = reader() + updatePageViewport(pageId, x, y) + viewportReaders.remove(pageId) + } + } + } + + private fun updatePageViewport(pageId: String, x: Float, y: Float) { + _pages.update { pages -> pages.map { + if (it.id == pageId) it.copy(scrollX = x.coerceAtLeast(0f), scrollY = y.coerceAtLeast(0f)) else it + } } + } + + /** Snapshot live controllers, not the possibly stale page copy or last rendered frame. */ + fun snapshotWorkspaceForSave(): com.dct.app.domain.model.project.DpcWorkspaceState { + viewportReaders.toMap().forEach { (id, read) -> + val (x, y) = read() + updatePageViewport(id, x, y) + } + val mdi = mdiViewModel.uiState.value + _pages.update { pages -> pages.map { page -> + val current = if (page.id == _activePageId.value) page.copy( + windows = mdi.windows, activeWindowId = mdi.activeWindowId, + boundSignals = chartRenderer.seriesListFlow.value, hasSavedLayout = true + ) else page + current.copy(windows = current.windows.map { window -> + _windowChartRenderers[window.id]?.let { renderer -> + window.copy(config = window.config.copy( + boundSignals = renderer.seriesListFlow.value, + chartState = renderer.exportWorkspaceState() + )) + } ?: window + }) + } } + return com.dct.app.domain.model.project.DpcWorkspaceState( + _activePageId.value, _pages.value.map { it.toSavedPage() } + ) + } + + /** Restore after data sources finish; missing references retain their saved metadata. */ + fun restoreProjectWorkspace(workspace: com.dct.app.domain.model.project.DpcWorkspaceState?) { + if (workspace == null || workspace.pages.isEmpty()) return + _windowChartRenderers.values.forEach { it.stop() } + _windowChartRenderers.clear() + viewportReaders.clear() + val restoredPages = workspace.pages.map { it.toRuntimePage() } + _pages.value = restoredPages + val active = restoredPages.find { it.id == workspace.activePageId } ?: restoredPages.first() + _activePageId.value = active.id + mdiViewModel.setWindows(active.windows, active.activeWindowId) + chartRenderer.restoreWorkspaceState(active.boundSignals, null) + chartRenderer.setTimeWindow(active.timeWindowPreset) + restoredPages.flatMap { it.windows }.filter { + it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) + }.forEach { getChartRenderer(it.id) } + _workspaceRevision.value++ + } + + /** + * 특정 윈도우 ID에 1:1 바인딩된 독립 ChartBatchRenderer 반환 (각 윈도우별 완전 격리) + */ + fun getChartRenderer(windowId: String): ChartBatchRenderer { + return _windowChartRenderers.computeIfAbsent(windowId) { wId -> + val renderer = ChartBatchRenderer(dispatchers) + renderer.setGraphInterpolationMode(_uiState.value.graphInterpolationMode) + renderer.start(scope) + if (!_uiState.value.isMeasuring) { + renderer.pause() + } else { + renderer.resume() + } + val win = mdiViewModel.uiState.value.windows.find { it.id == wId } + ?: _pages.value.asSequence().flatMap { it.windows.asSequence() }.find { it.id == wId } + if (win != null) { + if (win.type == MdiWindowType.MULTI_AXIS_GRAPH) { + renderer.setLegendPosition(2) // PC 사양: 다중축 기본 하단(Bottom) + } else { + renderer.setLegendPosition(0) // PC 사양: 단일축 기본 우측(Right) + } + renderer.restoreWorkspaceState(win.config.boundSignals, win.config.chartState) + if (win.config.chartState == null) renderer.setTimeWindow(win.config.timeWindowPreset) + } + renderer + } + } + + /** + * 차트 축 바인딩 처리 (AddToChartRequest 지원) + */ + fun handleAddToChart(req: AddToChartRequest) { + chartRenderer.bindSignal( + signalId = req.signalId, + signalName = req.signalName, + axisPosition = req.axisPosition, + color = req.color, + unit = req.unit + ) + } + + /** + * 신호 테이블 행 클릭/드래그 시 해당 창의 독립 차트 축에 신호 등록 + */ + fun handleAddToChart( + signalId: String, + signalName: String, + colorLong: Long, + unit: String, + isCan: Boolean = false, + minVal: Double? = null, + maxVal: Double? = null, + targetWindowId: String? = null + ) { + val currentWinList = mdiViewModel.uiState.value.windows + val graphWindows = currentWinList.filter { it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) } + val targetWin = if (targetWindowId != null) { + graphWindows.find { it.id == targetWindowId } + } else { + graphWindows.find { it.id == mdiViewModel.uiState.value.activeWindowId } ?: graphWindows.firstOrNull() + } + + // 1. 해당 윈도우 전용 독립 렌더러에 등록 (창별 완벽 격리) + if (targetWin != null) { + val renderer = getChartRenderer(targetWin.id) + val activeSeries = renderer.seriesListFlow.value + if (!activeSeries.any { it.signalId == signalId }) { + val targetAxis = if (targetWin.type == MdiWindowType.GRAPH) { + AxisPosition.LEFT_0 + } else { + val usedAxes = activeSeries.map { it.axisPosition }.toSet() + AxisPosition.LEFT_AXES.firstOrNull { it !in usedAxes } + ?: AxisPosition.LEFT_AXES[activeSeries.size % AxisPosition.LEFT_AXES.size] + } + + renderer.bindSignal( + signalId = signalId, + signalName = signalName, + axisPosition = targetAxis, + color = colorLong, + unit = unit, + minVal = minVal, + maxVal = maxVal + ) + + val updatedSeries = renderer.seriesListFlow.value + mdiViewModel.updateWindowConfig( + targetWin.id, + targetWin.config.copy(boundSignals = updatedSeries) + ) + } + } + + // 2. 기본 chartRenderer (단일 차트 및 테스트 호환성 유지) + val globalActive = chartRenderer.seriesListFlow.value + if (!globalActive.any { it.signalId == signalId }) { + val usedAxes = globalActive.map { it.axisPosition }.toSet() + val targetAxis = AxisPosition.LEFT_AXES.firstOrNull { it !in usedAxes } + ?: AxisPosition.LEFT_AXES[globalActive.size % AxisPosition.LEFT_AXES.size] + + chartRenderer.bindSignal( + signalId = signalId, + signalName = signalName, + axisPosition = targetAxis, + color = colorLong, + unit = unit, + minVal = minVal, + maxVal = maxVal + ) + } + + _pages.update { list -> + list.map { page -> + if (page.id == _activePageId.value) { + page.copy( + boundSignals = chartRenderer.seriesListFlow.value, + windows = mdiViewModel.uiState.value.windows + ) + } else page + } + } + } + + /** + * 드래그 앤 드롭으로 신호가 특정 서브윈도우에 놓였을 때 통합 처리 + * (Alphanumerical 창 -> 신호 추가, Graph 창 -> 차트 바인딩) + */ + fun handleUniversalSignalDrop( + signal: com.dct.app.presentation.chart.DraggedSignal, + targetWindowId: String? = null + ) { + val effectiveTargetId = targetWindowId ?: mdiViewModel.uiState.value.activeWindowId + val targetWin = mdiViewModel.uiState.value.windows.find { it.id == effectiveTargetId } + ?: mdiViewModel.uiState.value.windows.find { it.type in setOf(MdiWindowType.ALPHANUMERICAL, MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) } + + if (targetWin != null) { + if (targetWin.type == MdiWindowType.ALPHANUMERICAL) { + addAlphanumericalSignal(targetWin.id, signal.signalId) + } else if (targetWin.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH)) { + val raw = signal.rawSignal as? com.dct.app.domain.model.mdb.SignalDefinition + handleAddToChart( + signalId = signal.signalId, + signalName = signal.signalName, + colorLong = signal.colorLong, + unit = signal.unit, + isCan = signal.isCan, + minVal = raw?.min, + maxVal = raw?.max, + targetWindowId = targetWin.id + ) + } + } + } + + /** + * 그래프에서 특정 신호 바인딩 해제 (특정 윈도우 한정) + */ + fun unbindChartSignal(signalId: String, targetWindowId: String? = null) { + val currentWinList = mdiViewModel.uiState.value.windows + val graphWindows = currentWinList.filter { it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) } + val targetWin = if (targetWindowId != null) { + graphWindows.find { it.id == targetWindowId } + } else { + graphWindows.find { it.id == mdiViewModel.uiState.value.activeWindowId } ?: graphWindows.firstOrNull() + } + + if (targetWin != null) { + val renderer = getChartRenderer(targetWin.id) + renderer.unbindSignal(signalId) + val remainingSeries = renderer.seriesListFlow.value + + mdiViewModel.updateWindowConfig( + targetWin.id, + targetWin.config.copy(boundSignals = remainingSeries) + ) + } + chartRenderer.unbindSignal(signalId) + + _pages.update { list -> + list.map { page -> + if (page.id == _activePageId.value) { + page.copy( + boundSignals = chartRenderer.seriesListFlow.value, + windows = mdiViewModel.uiState.value.windows + ) + } else page + } + } + } + + /** + * 그래프의 모든 신호 바인딩 초기화 (특정 윈도우 한정 또는 전체) + */ + fun clearChartSignals(targetWindowId: String? = null) { + val currentWinList = mdiViewModel.uiState.value.windows + val graphWindows = currentWinList.filter { it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) } + if (targetWindowId != null) { + val targetWin = graphWindows.find { it.id == targetWindowId } + if (targetWin != null) { + val renderer = getChartRenderer(targetWin.id) + val existingSignalIds = renderer.seriesListFlow.value.map { it.signalId } + for (id in existingSignalIds) { + renderer.unbindSignal(id) + } + renderer.clearAllData() + + mdiViewModel.updateWindowConfig( + targetWin.id, + targetWin.config.copy(boundSignals = emptyList()) + ) + } + } else { + for (win in graphWindows) { + val renderer = getChartRenderer(win.id) + val existingSignalIds = renderer.seriesListFlow.value.map { it.signalId } + for (id in existingSignalIds) { + renderer.unbindSignal(id) + } + renderer.clearAllData() + + mdiViewModel.updateWindowConfig( + win.id, + win.config.copy(boundSignals = emptyList()) + ) + } + val existingGlobalIds = chartRenderer.seriesListFlow.value.map { it.signalId } + for (id in existingGlobalIds) { + chartRenderer.unbindSignal(id) + } + chartRenderer.clearAllData() + } + + _pages.update { list -> + list.map { page -> + if (page.id == _activePageId.value) { + page.copy( + boundSignals = emptyList(), + windows = mdiViewModel.uiState.value.windows + ) + } else page + } + } + } + + /** + * 기본 Signal list MDI 서브윈도우 인스턴스 생성 + */ + private fun createDefaultSignalListWindow(): com.dct.app.presentation.mdi.MdiWindow { + return com.dct.app.presentation.mdi.MdiWindow( + id = "win_signal_list_${java.util.UUID.randomUUID().toString().take(6)}", + type = MdiWindowType.SIGNAL_LIST, + title = "Signal list", + isClosable = true, + offsetX = 16f, + offsetY = 16f, + width = 240f, + height = 520f, + zIndex = 1f + ) + } + + private fun isMatchingSignalKey(key1: String, key2: String): Boolean { + if (key1 == key2) return true + val namespaces = listOf("sig_", "can_", "a2l_", "formula_", "sm_") + val firstNamespace = namespaces.find { key1.startsWith(it) } + val secondNamespace = namespaces.find { key2.startsWith(it) } + if (firstNamespace != null && secondNamespace != null && firstNamespace != secondNamespace) return false + val norm1 = key1.removePrefix("sig_").removePrefix("mdb_").removePrefix("can_").removePrefix("a2l_").removePrefix("formula_").removePrefix("sm_") + val norm2 = key2.removePrefix("sig_").removePrefix("mdb_").removePrefix("can_").removePrefix("a2l_").removePrefix("formula_").removePrefix("sm_") + if (norm1.equals(norm2, ignoreCase = true)) return true + + // MDB sig_reclid_idx 와 신호명 대조 + val db = _uiState.value.mdbDatabase + if (db != null) { + val sig1 = db.allSignals.find { "sig_${it.reclid}_${it.idx}" == key1 || it.name.equals(key1, ignoreCase = true) } + val sig2 = db.allSignals.find { "sig_${it.reclid}_${it.idx}" == key2 || it.name.equals(key2, ignoreCase = true) } + if (sig1 != null && sig2 != null && sig1.reclid == sig2.reclid && sig1.idx == sig2.idx) return true + } + + // CAN 신호 대조 + val canList = _canSignals.value + val can1 = canList.find { "can_${it.idx}" == key1 || it.name.equals(key1, ignoreCase = true) } + val can2 = canList.find { "can_${it.idx}" == key2 || it.name.equals(key2, ignoreCase = true) } + if (can1 != null && can2 != null && can1.idx == can2.idx && can1.name == can2.name) return true + + // A2L 신호 대조 + val a2lList = _a2lSignals.value + val a2l1 = a2lList.find { "a2l_${it.adl}" == key1 || it.name.equals(key1, ignoreCase = true) } + val a2l2 = a2lList.find { "a2l_${it.adl}" == key2 || it.name.equals(key2, ignoreCase = true) } + if (a2l1 != null && a2l2 != null && a2l1.adl == a2l2.adl && a2l1.name == a2l2.name) return true + + return false + } + + /** + * Alphanumerical 창에 신호 추가 (config.targetSignals 리스트에 저장, 중복 방지 및 식별자 정규화) + */ + fun addAlphanumericalSignal(windowId: String, signalId: String) { + if (signalId.isBlank() || signalId == "sig_0_0" || signalId == "0_0") return + val win = mdiViewModel.uiState.value.windows.find { it.id == windowId } ?: return + if (win.config.targetSignals.any { isMatchingSignalKey(it, signalId) }) return + val newTargetSignals = win.config.targetSignals + signalId + mdiViewModel.updateWindowConfig( + windowId, + win.config.copy(targetSignals = newTargetSignals) + ) + _pages.update { list -> + list.map { page -> + if (page.id == _activePageId.value) { + page.copy(windows = mdiViewModel.uiState.value.windows) + } else page + } + } + } + + /** + * Alphanumerical 창에서 신호 삭제 + */ + fun removeAlphanumericalSignal(windowId: String, signalId: String) { + val win = mdiViewModel.uiState.value.windows.find { it.id == windowId } ?: return + val newTargetSignals = win.config.targetSignals.filterNot { isMatchingSignalKey(it, signalId) } + mdiViewModel.updateWindowConfig( + windowId, + win.config.copy(targetSignals = newTargetSignals) + ) + _pages.update { list -> + list.map { page -> + if (page.id == _activePageId.value) { + page.copy(windows = mdiViewModel.uiState.value.windows) + } else page + } + } + } + + /** + * Alphanumerical 창의 모든 신호 일괄 삭제 + */ + fun clearAlphanumericalSignals(windowId: String) { + val win = mdiViewModel.uiState.value.windows.find { it.id == windowId } ?: return + mdiViewModel.updateWindowConfig( + windowId, + win.config.copy(targetSignals = emptyList()) + ) + _pages.update { list -> + list.map { page -> + if (page.id == _activePageId.value) { + page.copy(windows = mdiViewModel.uiState.value.windows) + } else page + } + } + } + + /** + * 특정 차트 신호의 표시/숨김 토글 + */ + fun toggleChartSignalVisibility(signalId: String, targetWindowId: String? = null) { + val targetId = targetWindowId ?: mdiViewModel.uiState.value.activeWindowId ?: return + val renderer = getChartRenderer(targetId) + val currentSeries = renderer.seriesListFlow.value.find { it.signalId == signalId } ?: return + renderer.setSeriesVisible(signalId, !currentSeries.isVisible) + } + + /** + * 특정 차트 신호의 선 두께 설정 (1~5px) + */ + fun setChartSignalThickness(signalId: String, thickness: Float, targetWindowId: String? = null) { + val targetId = targetWindowId ?: mdiViewModel.uiState.value.activeWindowId ?: return + val renderer = getChartRenderer(targetId) + renderer.setSeriesThickness(signalId, thickness) + } + + /** PC ribbon Graph Type is shared by every graph, including inactive pages. */ + fun setGraphInterpolationMode(mode: InterpolationMode) { + applyGraphInterpolationMode(mode) + _currentProject.update { it?.copy(graphLinear = mode == InterpolationMode.LINEAR) } + } + + private fun applyGraphInterpolationMode(mode: InterpolationMode) { + _uiState.update { it.copy(graphInterpolationMode = mode) } + chartRenderer.setGraphInterpolationMode(mode) + _windowChartRenderers.values.forEach { it.setGraphInterpolationMode(mode) } + + fun updateWindow(window: com.dct.app.presentation.mdi.MdiWindow) = window.copy( + config = window.config.copy(boundSignals = window.config.boundSignals.map { + it.copy(interpolationMode = mode) + }) + ) + val mdi = mdiViewModel.uiState.value + mdiViewModel.setWindows(mdi.windows.map(::updateWindow), mdi.activeWindowId) + _pages.update { pages -> pages.map { page -> + page.copy( + boundSignals = page.boundSignals.map { it.copy(interpolationMode = mode) }, + windows = page.windows.map(::updateWindow) + ) + } } + } + + /** + * 특정 차트 신호의 보간 방식 설정 (Linear / Step) + */ + fun setChartSignalInterpolation(signalId: String, mode: com.dct.app.domain.model.chart.InterpolationMode, targetWindowId: String? = null) { + val targetId = targetWindowId ?: mdiViewModel.uiState.value.activeWindowId ?: return + val renderer = getChartRenderer(targetId) + renderer.setSeriesInterpolationMode(signalId, mode) + } + + /** + * 특정 신호의 Y축 Min/Max 범위 및 Auto Scale 설정 + */ + fun setChartAxisMinMax(signalId: String, minVal: Double, maxVal: Double, isAuto: Boolean, targetWindowId: String? = null) { + val targetId = targetWindowId ?: mdiViewModel.uiState.value.activeWindowId ?: return + val renderer = getChartRenderer(targetId) + val series = renderer.seriesListFlow.value.find { it.signalId == signalId } ?: return + val pos = series.axisPosition + renderer.setAxisMinMax(pos, minVal, maxVal) + renderer.setAxisAuto(pos, isAuto) + } + + /** + * X축 시간 윈도우 프리셋 변경 (10s, 30s, 1m, 5m, 10m, Auto) + */ + fun setChartTimeWindowPreset(preset: com.dct.app.domain.model.chart.TimeWindowPreset, targetWindowId: String? = null) { + val targetId = targetWindowId ?: mdiViewModel.uiState.value.activeWindowId ?: return + val renderer = getChartRenderer(targetId) + renderer.setTimeWindow(preset) + } + + + /** + * 프로젝트 전체 탭을 고려하여 순차 번호가 매겨진 새 Graph 윈도우 생성 ("GraphWindow 01", "GraphWindow 02" 등 전역 번호 부여) + */ + fun addGraphWindow( + defaultWidth: Float = 480f, + defaultHeight: Float = 320f, + viewportScrollX: Float = 0f, + viewportScrollY: Float = 0f + ): String { + val allProjectWindows = _pages.value.flatMap { it.windows } + mdiViewModel.uiState.value.windows + return mdiViewModel.addGraphWindow( + defaultWidth = defaultWidth, + defaultHeight = defaultHeight, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY, + allProjectWindows = allProjectWindows + ) + } + + /** + * 프로젝트 전체 탭을 고려하여 순차 번호가 매겨진 새 Alphanumerical 윈도우 생성 ("Alphanumerical Window 01", "Alphanumerical Window 02" 등 전역 번호 부여) + */ + fun addAlphanumericalWindow( + defaultWidth: Float = 380f, + defaultHeight: Float = 320f, + viewportScrollX: Float = 0f, + viewportScrollY: Float = 0f + ): String { + val allProjectWindows = _pages.value.flatMap { it.windows } + mdiViewModel.uiState.value.windows + return mdiViewModel.addAlphanumericalWindow( + defaultWidth = defaultWidth, + defaultHeight = defaultHeight, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY, + allProjectWindows = allProjectWindows + ) + } + + /** + * 워크스페이스 페이지 전환 (프로젝트 전역 상태 및 데이터 버퍼는 100% 공유 유지, 화면 창 목록만 전환) + */ + fun switchPage(pageId: String) { + if (_activePageId.value == pageId || _pages.value.none { it.id == pageId }) return + + // 1. 현재 페이지의 차트 바인딩 및 서브윈도우 상태 스냅샷 저장 + val currentSeries = chartRenderer.seriesListFlow.value + val currentWindows = mdiViewModel.uiState.value.windows + val currentActiveId = _activePageId.value + _pages.update { list -> + list.map { page -> + if (page.id == currentActiveId) { + page.copy( + boundSignals = currentSeries, + windows = currentWindows, + activeWindowId = mdiViewModel.uiState.value.activeWindowId, + hasSavedLayout = true + ) + } else { + page + } + } + } + + // 2. 활성 페이지 ID 갱신 + _activePageId.value = pageId + + // 3. 대상 페이지의 MDI 윈도우 화면 뷰 복원 (기존 수집 데이터는 초기화하지 않고 그대로 유지) + val targetPage = _pages.value.find { it.id == pageId } + if (targetPage != null) { + val hasLoadedData = _uiState.value.mdbDatabase != null || _canSignals.value.isNotEmpty() + val restoredWindows = if (targetPage.windows.isNotEmpty()) { + targetPage.windows + } else if (hasLoadedData && !targetPage.hasSavedLayout) { + listOf(createDefaultSignalListWindow()) + } else { + emptyList() + } + mdiViewModel.setWindows(restoredWindows, targetPage.activeWindowId) + + // 대상 페이지에 속한 신호가 차트 렌더러에 없으면 바인딩 보강 (기존 데이터는 삭제하지 않음) + val allTargetSignals = (targetPage.boundSignals + restoredWindows.flatMap { it.config.boundSignals }) + .distinctBy { it.signalId } + + val currentBoundIds = chartRenderer.seriesListFlow.value.map { it.signalId }.toSet() + for (s in allTargetSignals) { + if (s.signalId !in currentBoundIds) { + chartRenderer.bindSignal( + signalId = s.signalId, + signalName = s.signalName, + axisPosition = s.axisPosition, + color = s.color, + unit = s.unit, + interpolationMode = s.interpolationMode, + lineWidthDp = s.lineWidthDp + ) + } + } + chartRenderer.setTimeWindow(targetPage.timeWindowPreset) + } + } + + private fun findNextAvailablePageNumber(): Int { + val existingNums = _pages.value.mapNotNull { page -> + val match = Regex("""\d+""").find(page.title) + match?.value?.toIntOrNull() + }.toSet() + var nextNum = 1 + while (existingNums.contains(nextNum)) { + nextNum++ + } + return nextNum + } + + companion object { + const val MAX_WORKSPACE_PAGES = 5 + } + + /** + * 워크스페이스 페이지 탭 추가 (최대 5개 제한) + */ + fun addPage(): String { + if (_pages.value.size >= MAX_WORKSPACE_PAGES) { + return _activePageId.value + } + val newPageNum = findNextAvailablePageNumber() + val newId = "page_${System.currentTimeMillis()}_$newPageNum" + val newTitle = "Page $newPageNum" + + val hasLoadedData = _uiState.value.mdbDatabase != null || _canSignals.value.isNotEmpty() + val initialWindows = if (hasLoadedData) { + listOf(createDefaultSignalListWindow()) + } else { + emptyList() + } + + val newPage = WorkspacePageState( + id = newId, + title = newTitle, + windows = initialWindows + ) + + val currentSeries = chartRenderer.seriesListFlow.value + val currentWindows = mdiViewModel.uiState.value.windows + val currentActiveId = _activePageId.value + + _pages.update { list -> + list.map { page -> + if (page.id == currentActiveId) page.copy(boundSignals = currentSeries, windows = currentWindows, + activeWindowId = mdiViewModel.uiState.value.activeWindowId, hasSavedLayout = true) else page + } + newPage + } + + switchPage(newId) + return newId + } + + /** + * 워크스페이스 페이지 탭 삭제 (Page 1은 삭제 불가) + */ + fun removePage(pageId: String) { + val currentPages = _pages.value + if (currentPages.size <= 1) return // 최소 1개 페이지 유지 + + val targetPage = currentPages.find { it.id == pageId } + if (targetPage == null || targetPage.id == "page_1" || targetPage.title.equals("Page 1", ignoreCase = true)) { + return // Page 1은 삭제 불가 + } + + val pageIndex = currentPages.indexOfFirst { it.id == pageId } + if (pageIndex == -1) return + + val newPages = currentPages.filter { it.id != pageId } + _pages.value = newPages + + if (_activePageId.value == pageId) { + val nextIndex = (pageIndex - 1).coerceAtLeast(0) + val nextTarget = newPages[nextIndex] + _activePageId.value = nextTarget.id + + val hasLoadedData = _uiState.value.mdbDatabase != null || _canSignals.value.isNotEmpty() + if (nextTarget.windows.isNotEmpty()) { + mdiViewModel.setWindows(nextTarget.windows, nextTarget.activeWindowId) + } else if (hasLoadedData && !nextTarget.hasSavedLayout) { + mdiViewModel.setWindows(listOf(createDefaultSignalListWindow())) + } else { + mdiViewModel.setWindows(emptyList()) + } + } + } + + /** + * 특정 서브윈도우를 다음 워크스페이스 페이지로 이동 (현재 보고 있는 화면/페이지는 유지) + */ + /** Move and activate together, preserving the keyed composable and in-flight header gesture. */ + fun moveWindowToPageAndSwitch(windowId: String, pageId: String, x: Float, y: Float): Boolean { + if (!x.isFinite() || !y.isFinite() || pageId == _activePageId.value) return false + val destination = _pages.value.find { it.id == pageId } ?: return false + val sourceId = _activePageId.value + val mdi = mdiViewModel.uiState.value + val window = mdi.windows.find { it.id == windowId } ?: return false + if (destination.windows.any { it.id == windowId }) return false + if (destination.windows.size >= mdi.maxWindowsLimit) { + _uiState.update { it.copy(statusMessage = "${destination.title}의 창 개수 한도에 도달했습니다.") } + return false + } + + // Keep the same window ID and renderer/buffer. Never close or recreate the dragged window. + val renderer = _windowChartRenderers[windowId] + val moved = window.copy( + offsetX = x.coerceAtLeast(0f), offsetY = y.coerceAtLeast(0f), + zIndex = (destination.windows.maxOfOrNull { it.zIndex } ?: 0f) + 1f, + config = if (renderer == null) window.config else window.config.copy( + boundSignals = renderer.seriesListFlow.value, chartState = renderer.exportWorkspaceState() + ) + ) + val remaining = mdi.windows.filterNot { it.id == windowId } + val destinationWindows = destination.windows + moved + val destinationSignals = (destination.boundSignals + moved.config.boundSignals).distinctBy { it.signalId } + _pages.update { pages -> pages.map { page -> + when (page.id) { + sourceId -> page.copy( + windows = remaining, + activeWindowId = mdi.activeWindowId?.takeIf { id -> remaining.any { it.id == id } } + ?: remaining.lastOrNull()?.id, + boundSignals = chartRenderer.seriesListFlow.value, + hasSavedLayout = true + ) + pageId -> page.copy(windows = destinationWindows, activeWindowId = windowId, + boundSignals = destinationSignals, hasSavedLayout = true) + else -> page + } + } } + // Unlike switchPage(), don't snapshot the old MDI list over the source page again. + _activePageId.value = pageId + mdiViewModel.setWindows(destinationWindows, windowId) + val existing = chartRenderer.seriesListFlow.value.mapTo(mutableSetOf()) { it.signalId } + (destinationSignals + destinationWindows.flatMap { it.config.boundSignals }) + .distinctBy { it.signalId }.filterNot { it.signalId in existing }.forEach { series -> + chartRenderer.bindSignal(series.signalId, series.signalName, series.axisPosition, + series.color, series.unit, series.interpolationMode, series.lineWidthDp) + } + chartRenderer.setTimeWindow(destination.timeWindowPreset) + return true + } + + /** 특정 서브윈도우를 다음 페이지로 이동 (현재 보고 있는 페이지는 유지). */ + fun moveWindowToNextPage(windowId: String) { + val currentWindows = mdiViewModel.uiState.value.windows + val targetWin = currentWindows.find { it.id == windowId } ?: return + val currentPages = _pages.value + val currentIndex = currentPages.indexOfFirst { it.id == _activePageId.value } + if (currentIndex == -1) return + + // 윈도우가 그래프 창인 경우 현재 차트 시리즈 스냅샷을 윈도우 config.boundSignals에 보존 + val currentSeries = chartRenderer.seriesListFlow.value + val winWithSignals = if (targetWin.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH)) { + val signalsToKeep = if (targetWin.config.boundSignals.isNotEmpty()) targetWin.config.boundSignals else currentSeries + targetWin.copy(config = targetWin.config.copy(boundSignals = signalsToKeep)) + } else { + targetWin + } + + val targetPageId: String + val targetPageTitle: String + + if (currentIndex < currentPages.size - 1) { + val targetPage = currentPages[currentIndex + 1] + targetPageId = targetPage.id + targetPageTitle = targetPage.title + + val targetNewBoundSignals = (targetPage.boundSignals + winWithSignals.config.boundSignals).distinctBy { it.signalId } + + _pages.update { list -> + list.map { page -> + if (page.id == targetPageId) { + page.copy( + windows = page.windows + winWithSignals, + boundSignals = targetNewBoundSignals + ) + } else page + } + } + } else { + // 마지막 페이지이거나 페이지가 1개뿐인 경우 새 페이지 생성하여 해당 페이지로 창 이동 + val newPageNum = findNextAvailablePageNumber() + targetPageId = "page_${System.currentTimeMillis()}_$newPageNum" + targetPageTitle = "Page $newPageNum" + + val hasLoadedData = _uiState.value.mdbDatabase != null || _canSignals.value.isNotEmpty() + val initialWindows = if (hasLoadedData) { + listOf(createDefaultSignalListWindow(), winWithSignals) + } else { + listOf(winWithSignals) + } + + val newPage = WorkspacePageState( + id = targetPageId, + title = targetPageTitle, + windows = initialWindows, + boundSignals = winWithSignals.config.boundSignals + ) + + _pages.update { list -> list + newPage } + } + + // 현재 페이지에서 해당 윈도우 제거 (현재 페이지 화면은 그대로 유지) + mdiViewModel.closeWindow(windowId) + + // 만약 현재 페이지에 더 이상 그래프 윈도우가 남아있지 않다면, 현재 페이지의 chartRenderer 바인딩 해제 + val remainingGraphWindows = mdiViewModel.uiState.value.windows.filter { + it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) + } + if (remainingGraphWindows.isEmpty()) { + clearChartSignals() + } + + _uiState.update { + it.copy(statusMessage = "'${targetWin.title}' 창을 $targetPageTitle(으)로 이동했습니다.") + } + } + + /** + * 특정 서브윈도우를 이전 워크스페이스 페이지로 이동 (현재 보고 있는 화면/페이지는 유지) + */ + fun moveWindowToPreviousPage(windowId: String) { + val currentWindows = mdiViewModel.uiState.value.windows + val targetWin = currentWindows.find { it.id == windowId } ?: return + val currentPages = _pages.value + val currentIndex = currentPages.indexOfFirst { it.id == _activePageId.value } + if (currentIndex == -1) return + + if (currentPages.size <= 1 && currentIndex == 0) { + // 단일 페이지에서 이전으로 보낼 때: 새 Page 2를 만들고 그곳으로 이동 + moveWindowToNextPage(windowId) + return + } + + val targetIndex = if (currentIndex > 0) currentIndex - 1 else currentPages.size - 1 + val targetPage = currentPages[targetIndex] + + val currentSeries = chartRenderer.seriesListFlow.value + val winWithSignals = if (targetWin.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH)) { + val signalsToKeep = if (targetWin.config.boundSignals.isNotEmpty()) targetWin.config.boundSignals else currentSeries + targetWin.copy(config = targetWin.config.copy(boundSignals = signalsToKeep)) + } else { + targetWin + } + + val targetNewBoundSignals = (targetPage.boundSignals + winWithSignals.config.boundSignals).distinctBy { it.signalId } + + _pages.update { list -> + list.map { page -> + if (page.id == targetPage.id) { + page.copy( + windows = page.windows + winWithSignals, + boundSignals = targetNewBoundSignals + ) + } else page + } + } + + // 현재 페이지에서 해당 윈도우 제거 (현재 페이지 화면은 그대로 유지) + mdiViewModel.closeWindow(windowId) + + // 만약 현재 페이지에 더 이상 그래프 윈도우가 남아있지 않다면, 현재 페이지의 chartRenderer 바인딩 해제 + val remainingGraphWindows = mdiViewModel.uiState.value.windows.filter { + it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) + } + if (remainingGraphWindows.isEmpty()) { + clearChartSignals() + } + + _uiState.update { + it.copy(statusMessage = "'${targetWin.title}' 창을 ${targetPage.title}(으)로 이동했습니다.") + } + } + + /** + * 이전 페이지로 전환 (순환 지원) + */ + fun goToPreviousPage() { + val currentList = _pages.value + if (currentList.size <= 1) return + val currentIndex = currentList.indexOfFirst { it.id == _activePageId.value } + if (currentIndex > 0) { + switchPage(currentList[currentIndex - 1].id) + } else { + switchPage(currentList.last().id) + } + } + + /** + * 다음 페이지로 전환 (페이지가 1개뿐일 때 누르면 새 Page 2 자동 생성 및 전환) + */ + fun goToNextPage() { + val currentList = _pages.value + val currentIndex = currentList.indexOfFirst { it.id == _activePageId.value } + if (currentIndex >= 0 && currentIndex < currentList.size - 1) { + switchPage(currentList[currentIndex + 1].id) + } else if (currentList.size <= 1) { + addPage() + } else { + switchPage(currentList.first().id) + } + } + + /** + * 외부 SAF Uri로부터 MDB 파일 열기 및 로드 + */ + fun openMdbFromUri(context: Context, uri: Uri, fallbackFileName: String? = null): Job { + calibrationJob?.cancel() + return scope.launch(dispatchers.io) { + _uiState.update { it.copy(isMdbLoading = true, statusMessage = "MDB 파일 로딩 중...") } + val result = mdbRepository.openDatabase(context, uri) + when (result) { + is DctResult.Success -> { + val dbInfo = result.data + val db = mdbRepository.getCurrentDatabase()?.let(::applyProjectOverridesToMdb) + if (db != null) { + // Direct imports replace the other source; project restores may contain both. + if (!isProjectLoading) { + _a2lSignals.value = emptyList() + _a2lCharacteristics.value = emptyList() + _a2lCharacteristicsArray.value = emptyList() + mdiViewModel.closeWindowsByTypes( + MdiWindowType.A2L_MEASUREMENTS, + MdiWindowType.A2L_CHARACTERISTICS, + MdiWindowType.A2L_CHARACTERISTICS_ARRAY, + MdiWindowType.A2L_SIGNAL_LIST + ) + } + + treeViewModel.setDatabase(db) + + val initialTiles = db.allSignals.take(6).mapIndexed { _, sig -> + TileWidgetItem( + signalId = "sig_${sig.reclid}_${sig.idx}", + signalName = sig.name, + value = 0.0, + unit = sig.unit, + color = sig.color, + minLimit = sig.min, + maxLimit = sig.max + ) + } + + _uiState.update { + it.copy( + mdbDatabase = db, + mdbFileName = dbInfo.fileName, + a2lFileName = null, + isMdbLoading = false, + dashboardTiles = if (it.dashboardTiles.isEmpty()) initialTiles else it.dashboardTiles, + statusMessage = "MDB 로드 완료: ${dbInfo.fileName} (${db.allSignals.size}개 신호)" + ) + } + applyParameterDescriptions() + // 실제 MDB 파일이 로드되었을 때 Signal list 및 MDB Signal 테이블 서브윈도우를 캔버스에 나란히 생성 + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.SIGNAL_LIST, + title = "Signal list", + defaultWidth = 240f, + defaultHeight = 520f + ) + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.MDB_SIGNAL_LIST, + title = "MDB Signal", + defaultWidth = 420f, + defaultHeight = 520f + ) + eventBus.emit(AppEvent.MdbLoaded(dbInfo.fileName, db.allSignals.size)) + } else { + _uiState.update { + it.copy( + mdbFileName = dbInfo.fileName, + isMdbLoading = false, + statusMessage = "MDB 파일 로드 완료: ${dbInfo.fileName} (${dbInfo.tableCount}개 테이블)" + ) + } + } + } + is DctResult.Failure -> { + // Fallback to local imported file if available + val fn = fallbackFileName ?: uri.lastPathSegment?.substringAfterLast("/") + val localFallback = if (fn != null) File(context.filesDir, "imported_files/$fn") else null + if (localFallback != null && localFallback.exists() && localFallback.length() > 0) { + awaitProjectSource(openMdbFromFile(localFallback)) + } else { + _uiState.update { + it.copy( + isMdbLoading = false, + statusMessage = "MDB 로드 실패: ${result.error.message}" + ) + } + eventBus.emit(AppEvent.ErrorOccurred(result.error.message, isFatal = false)) + } + } + is DctResult.Loading -> {} + } + } + } + + /** + * 로컬 캐시/내부 저장소 File로부터 MDB 파일 열기 + */ + fun openMdbFromFile(file: File): Job { + calibrationJob?.cancel() + return scope.launch(dispatchers.io) { + _uiState.update { it.copy(isMdbLoading = true, statusMessage = "MDB 파일 로딩 중...") } + val result = mdbRepository.openDatabase(file) + when (result) { + is DctResult.Success -> { + val dbInfo = result.data + val db = mdbRepository.getCurrentDatabase()?.let(::applyProjectOverridesToMdb) + if (db != null) { + // Direct imports replace the other source; project restores may contain both. + if (!isProjectLoading) { + _a2lSignals.value = emptyList() + _a2lCharacteristics.value = emptyList() + _a2lCharacteristicsArray.value = emptyList() + mdiViewModel.closeWindowsByTypes( + MdiWindowType.A2L_MEASUREMENTS, + MdiWindowType.A2L_CHARACTERISTICS, + MdiWindowType.A2L_CHARACTERISTICS_ARRAY, + MdiWindowType.A2L_SIGNAL_LIST + ) + } + + treeViewModel.setDatabase(db) + + val initialTiles = db.allSignals.take(6).mapIndexed { _, sig -> + TileWidgetItem( + signalId = "sig_${sig.reclid}_${sig.idx}", + signalName = sig.name, + value = 0.0, + unit = sig.unit, + color = sig.color, + minLimit = sig.min, + maxLimit = sig.max + ) + } + + _uiState.update { + it.copy( + mdbDatabase = db, + mdbFileName = dbInfo.fileName, + a2lFileName = null, + isMdbLoading = false, + dashboardTiles = if (it.dashboardTiles.isEmpty()) initialTiles else it.dashboardTiles, + statusMessage = "MDB 로드 완료: ${dbInfo.fileName} (${db.allSignals.size}개 신호)" + ) + } + applyParameterDescriptions() + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.SIGNAL_LIST, + title = "Signal list", + defaultWidth = 240f, + defaultHeight = 520f + ) + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.MDB_SIGNAL_LIST, + title = "MDB Signal", + defaultWidth = 420f, + defaultHeight = 520f + ) + eventBus.emit(AppEvent.MdbLoaded(dbInfo.fileName, db.allSignals.size)) + } + } + is DctResult.Failure -> { + _uiState.update { + it.copy( + isMdbLoading = false, + statusMessage = "MDB 로드 실패: ${result.error.message}" + ) + } + } + is DctResult.Loading -> {} + } + } + } + + /** + * 프로젝트 전환 또는 신규 프로젝트 생성 시 화면 및 상태를 완전히 초기화 + * (기존 서브윈도우, 차트 바인딩, 페이지, 텔레메트리 데이터 완전 리셋) + */ + fun resetWorkspaceForNewProject() { + calibrationJob?.cancel() + _workspaceRevision.value++ + viewportReaders.clear() + _windowChartRenderers.values.forEach { it.stop() } + _windowChartRenderers.clear() + // 1. 실시간 계측 정지 + stopMeasure() + + // 2. MDI 서브윈도우 전체 삭제 및 카운터/오프셋 리셋 + mdiViewModel.clearAllWindows() + + // 3. 차트 바인딩 및 버퍼 데이터 전체 초기화 + clearChartSignals() + + // 4. 워크스페이스 멀티 페이지 초기화 (Page 1 단일 페이지만 유지) + applyGraphInterpolationMode(InterpolationMode.LINEAR) + _pages.value = listOf( + WorkspacePageState(id = "page_1", title = "Page 1") + ) + _activePageId.value = "page_1" + + // 5. 텔레메트리 수신 데이터 초기화 + _liveTelemetryValues.value = emptyMap() + + // 6. 신호 트리 및 캘리브레이션 뷰모델 초기화 + treeViewModel.clear() + calibrationViewModel.clear() + + // 7. 신호 및 특성치 목록 초기화 + _canSignals.value = emptyList() + _a2lSignals.value = emptyList() + _a2lCharacteristics.value = emptyList() + _a2lCharacteristicsArray.value = emptyList() + + // 8. 메인 UI 상태 초기화 (단, 기존 하드웨어 통신 연결 세션은 유지) + _uiState.update { + it.copy( + mdbDatabase = null, + mdbFileName = null, + canDbcFileName = null, + a2lFileName = null, + projectFileName = null, + isMeasuring = false, + packetCount = 0L, + dashboardTiles = emptyList(), + currentSelection = null, + statusMessage = null + ) + } + } + + /** Bluetooth는 현재 릴리스 범위에서 제외되어 연결을 시작하지 않습니다. */ + fun connectBluetooth(deviceName: String) { + _uiState.update { + it.copy( + isConnected = false, + communicationType = CommunicationType.NONE, + connectedDeviceName = null, + statusMessage = "Bluetooth [$deviceName] 연결은 추후 지원 예정입니다." + ) + } + } + + fun connectWifi( + deviceName: String, + hostIp: String = "192.168.4.1", + port: Int = 6000, + isGroupOwner: Boolean = false + ) { + val generation = connectionGeneration.incrementAndGet() + configurationRequired = true + val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트(GC)" + scope.launch(dispatchers.io) { + sessionMutex.withLock { + if (generation != connectionGeneration.get()) return@withLock + if (_uiState.value.isMeasuring || packetRepository.isConnected.value) { + packetRepository.disconnect() + } + finishMeasurementSession() // Also close remnants of an error/disconnect whose UI was already cleared. + _uiState.update { + it.copy(isConnected = false, communicationType = CommunicationType.WIFI, + connectedDeviceName = deviceName, + statusMessage = "Wi-Fi [$deviceName] ($hostIp:$port, $roleLabel) 통신 개시 중...") + } + wifiSocketManager.startDualMode( + isGroupOwner = isGroupOwner, hostIp = hostIp, port = port, + onConnected = { _, remoteAddress -> + scope.launch(dispatchers.io) { + sessionMutex.withLock { + if (generation != connectionGeneration.get()) return@withLock + configurationRequired = true + when (val result = packetRepository.connect(remoteAddress)) { + is DctResult.Success -> { + if (generation == connectionGeneration.get()) { + syncDeviceConfigurationLocked(generation) + } + } + is DctResult.Failure -> if (generation == connectionGeneration.get()) { + setStatusMessage(result.error.message) + } + DctResult.Loading -> Unit + } + } + } + }, + onWaiting = { message -> + if (generation == connectionGeneration.get()) setStatusMessage(message) + }, + onFailure = { error -> + if (generation == connectionGeneration.get()) { + _uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, + connectedDeviceName = null, statusMessage = "Wi-Fi 연결 실패: $error") } + } + } + ) + } + } + } + + private var registeredWifiDirectManager: com.dct.app.data.communication.WifiDirectManager? = null + + fun registerWifiDirectManager(manager: com.dct.app.data.communication.WifiDirectManager) { + registeredWifiDirectManager = manager + } + + /** + * Wi-Fi Direct / TCP 통신 연결 해제. + */ + fun disconnectCommunication(context: Context? = null) { + connectionGeneration.incrementAndGet() // Invalidate pending START/handshake callbacks immediately. + configurationRequired = true + _uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, + connectedDeviceName = null, statusMessage = "통신 연결 해제 중...") } + scope.launch(dispatchers.io) { + sessionMutex.withLock { + try { + packetRepository.disconnect() // Includes STOP when the repository is streaming. + } finally { + finishMeasurementSession() + wifiSocketManager.disconnectSuspend() + registeredWifiDirectManager?.disconnect() + if (context != null && registeredWifiDirectManager == null) { + try { + com.dct.app.data.communication.WifiDirectManager(context).disconnect() + } catch (_: Exception) {} + } + _uiState.update { + it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE, + connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.") + } + } + } + } + } + + /** + * 외부 SAF Uri로부터 .dpc 프로젝트 파일 열기 및 로드 + */ + fun openProjectFromUri(context: Context, uri: Uri) { + loadProjectAndWorkspace(context) { + com.dct.app.data.repository.DpcProjectManager.loadProject(context, uri) + } + } + + fun openProjectFromFile(context: Context, file: File) { + loadProjectAndWorkspace(context) { + com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(file) + } + } + + private fun loadProjectAndWorkspace( + context: Context, + load: () -> com.dct.app.domain.model.project.DpcProject? + ) { + val previous = projectLoadJob + projectLoadJob = scope.launch(dispatchers.io) { + previous?.cancel() + previous?.join() + isProjectLoading = true + try { + _uiState.update { it.copy(isMdbLoading = true, statusMessage = "프로젝트 로딩 중...") } + val project = load() + if (project == null) { + _uiState.update { it.copy(statusMessage = "프로젝트 로드 실패: 올바른 .dpc 파일이 아닙니다.") } + return@launch + } + withContext(dispatchers.mainImmediate) { + resetWorkspaceForNewProject() + setCurrentProject(project) + } + fun sourceFile(localPath: String?, name: String, folder: String): File? { + if (!localPath.isNullOrBlank()) File(localPath).takeIf { it.isFile }?.let { return it } + if (name.isBlank()) return null + return listOf( + File(com.dct.app.data.repository.DpcProjectManager.getDefaultSubFolder(folder, context), name), + File(context.filesDir, "imported_files/$name") + ).firstOrNull { it.isFile } + } + val mdb = sourceFile(project.localMdbPath, project.mdbFileName, "MDB") + if (mdb != null) awaitProjectSource(openMdbFromFile(mdb)) + else project.mdbFileUri?.let { awaitProjectSource(openMdbFromUri(context, it, project.mdbFileName)) } + val dbc = sourceFile(project.localCanDbcPath, project.canDbcFileName, "DBC") + if (dbc != null) awaitProjectSource(openCanDbcFromFile(dbc)) + else project.canDbcFileUri?.let { awaitProjectSource(openCanDbcFromUri(context, it, project.canDbcFileName)) } + val a2l = sourceFile(project.localA2lPath, project.a2lFileName, "A2L") + if (a2l != null) awaitProjectSource(openA2lFromFile(a2l)) + else project.a2lFileUri?.let { awaitProjectSource(openA2lFromUri(context, it, project.a2lFileName)) } + + withContext(dispatchers.mainImmediate) { + applyParameterDescriptions() + restoreProjectWorkspace(project.workspace) + val missingSources = listOfNotNull( + project.mdbFileName.takeIf { it.isNotBlank() && _uiState.value.mdbDatabase == null }, + project.canDbcFileName.takeIf { it.isNotBlank() && _canSignals.value.isEmpty() }, + project.a2lFileName.takeIf { it.isNotBlank() && _a2lSignals.value.isEmpty() } + ) + val warning = if (missingSources.isEmpty()) "" else + " (원본 파일 확인 필요: ${missingSources.joinToString()}; 창과 신호 참조는 유지됩니다)" + _uiState.update { it.copy( + projectFileName = project.projectFileName, + mdbFileName = project.mdbFileName.ifEmpty { null }, + canDbcFileName = project.canDbcFileName.ifEmpty { null }, + a2lFileName = project.a2lFileName.ifEmpty { null }, + statusMessage = "프로젝트 로드 완료: ${project.projectFileName}$warning" + ) } + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + _uiState.update { it.copy(statusMessage = "프로젝트 로드 실패: ${e.message}") } + } finally { + isProjectLoading = false + _uiState.update { it.copy(isMdbLoading = false) } + } + } + } + + private suspend fun awaitProjectSource(job: Job) { + try { + job.join() + } finally { + if (!kotlinx.coroutines.currentCoroutineContext().isActive) { + job.cancel() + withContext(kotlinx.coroutines.NonCancellable) { job.join() } + } + } + } + + /** + * 외부 SAF Uri로부터 CAN DBC 파일 열기 및 파싱 + */ + fun openCanDbcFromUri(context: Context, uri: Uri, fallbackFileName: String? = null): Job { + return scope.launch(dispatchers.io) { + try { + var stream: java.io.InputStream? = null + try { + stream = context.contentResolver.openInputStream(uri) + } catch (se: SecurityException) { + val fn = fallbackFileName ?: uri.lastPathSegment?.substringAfterLast("/") ?: "network.dbc" + val localBackup = File(context.filesDir, "imported_files/$fn") + if (localBackup.exists()) { + stream = localBackup.inputStream() + } + } + + if (stream == null && fallbackFileName != null) { + val localBackup = File(context.filesDir, "imported_files/$fallbackFileName") + if (localBackup.exists()) { + stream = localBackup.inputStream() + } + } + + if (stream == null) { + _uiState.update { + it.copy(statusMessage = "CAN DBC 파일 접근 권한이 만료되었습니다. 파일을 다시 선택해 주세요.") + } + return@launch + } + + val dbcName = fallbackFileName ?: uri.lastPathSegment?.substringAfterLast("/") ?: "network.dbc" + stream.use { s -> + val signals = com.dct.app.data.parser.CanDbcParser.parseDbc(s) + _canSignals.value = applyProjectOverridesToCan(signals) + _uiState.update { + it.copy( + canDbcFileName = dbcName, + statusMessage = "CAN DBC 로드 완료 ($dbcName: ${signals.size}개 신호)" + ) + } + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.CAN_SIGNAL_LIST, + title = "CAN Signal", + defaultWidth = 420f, + defaultHeight = 520f + ) + } + } catch (e: Exception) { + _uiState.update { it.copy(statusMessage = "CAN DBC 로드 실패: ${e.message}") } + } + } + } + + /** + * 로컬 File로부터 CAN DBC 파일 열기 및 파싱 + */ + fun openCanDbcFromFile(file: File): Job { + return scope.launch(dispatchers.io) { + try { + file.inputStream().use { stream -> + val signals = com.dct.app.data.parser.CanDbcParser.parseDbc(stream) + _canSignals.value = applyProjectOverridesToCan(signals) + _uiState.update { + it.copy( + canDbcFileName = file.name, + statusMessage = "CAN DBC 로드 완료 (${signals.size}개 신호)" + ) + } + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.SIGNAL_LIST, + title = "Signal list", + defaultWidth = 240f, + defaultHeight = 520f + ) + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.CAN_SIGNAL_LIST, + title = "CAN Signal", + defaultWidth = 420f, + defaultHeight = 520f + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy(statusMessage = "CAN DBC 로드 실패: ${e.message}") + } + } + } + } + + /** + * 외부 SAF Uri로부터 A2L 파일 열기 및 파싱 + */ + fun openA2lFromUri(context: Context, uri: Uri, fallbackFileName: String? = null): Job { + calibrationJob?.cancel() + return scope.launch(dispatchers.io) { + try { + var stream: java.io.InputStream? = null + try { + stream = context.contentResolver.openInputStream(uri) + } catch (se: SecurityException) { + val fn = fallbackFileName ?: uri.lastPathSegment?.substringAfterLast("/") ?: "ecu.a2l" + val localBackup = File(context.filesDir, "imported_files/$fn") + if (localBackup.exists()) { + stream = localBackup.inputStream() + } + } + + if (stream == null && fallbackFileName != null) { + val localBackup = File(context.filesDir, "imported_files/$fallbackFileName") + if (localBackup.exists()) { + stream = localBackup.inputStream() + } + } + + if (stream == null) { + _uiState.update { + it.copy(statusMessage = "A2L 파일 접근 권한이 만료되었습니다. 파일을 다시 선택해 주세요.") + } + return@launch + } + + val a2lName = fallbackFileName ?: uri.lastPathSegment?.substringAfterLast("/") ?: "ecu.a2l" + stream.use { s -> + if (!isProjectLoading) { + _uiState.update { it.copy(mdbDatabase = null, mdbFileName = null) } + mdiViewModel.closeWindowsByTypes( + MdiWindowType.MDB_SIGNAL_LIST, + MdiWindowType.CONSTANT_SIGNAL_LIST, + MdiWindowType.COMPENSATION_TYPE_A, + MdiWindowType.COMPENSATION_TYPE_B, + MdiWindowType.MDB_TABLE + ) + } + + val parseResult = com.dct.app.data.parser.A2lParser.parseA2lFull(s) + val overriddenSignals = applyProjectOverridesToA2l(parseResult.measurements) + _a2lSignals.value = overriddenSignals + _a2lCharacteristics.value = parseResult.characteristics + _a2lCharacteristicsArray.value = parseResult.characteristicsArray + applyParameterDescriptions() + currentXcpIfData = parseResult.ifData + + xcpDaqDecoder.configureDaqSignals( + signals = overriddenSignals, + rawMeasurements = parseResult.rawMeasurements, + compuMethods = parseResult.compuMethods, + isMsbFirst = parseResult.ifData?.isMsbFirst ?: false + ) + + _uiState.update { + it.copy( + a2lFileName = a2lName, + statusMessage = "A2L 로드 완료 (${parseResult.measurements.size}개 Measurements, ${parseResult.characteristics.size}개 Characteristics)" + ) + } + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.SIGNAL_LIST, + title = "Signal list", + defaultWidth = 240f, + defaultHeight = 520f + ) + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.A2L_MEASUREMENTS, + title = "Measurements", + defaultWidth = 420f, + defaultHeight = 520f + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy(statusMessage = "A2L 로드 실패: ${e.message}") + } + } + } + } + + /** + * 로컬 File로부터 A2L 파일 열기 및 파싱 + */ + fun openA2lFromFile(file: File): Job { + calibrationJob?.cancel() + return scope.launch(dispatchers.io) { + try { + file.inputStream().use { stream -> + if (!isProjectLoading) { + _uiState.update { it.copy(mdbDatabase = null, mdbFileName = null) } + mdiViewModel.closeWindowsByTypes( + MdiWindowType.MDB_SIGNAL_LIST, + MdiWindowType.CONSTANT_SIGNAL_LIST, + MdiWindowType.COMPENSATION_TYPE_A, + MdiWindowType.COMPENSATION_TYPE_B, + MdiWindowType.MDB_TABLE + ) + } + + val parseResult = com.dct.app.data.parser.A2lParser.parseA2lFull(stream) + val overriddenSignals = applyProjectOverridesToA2l(parseResult.measurements) + _a2lSignals.value = overriddenSignals + _a2lCharacteristics.value = parseResult.characteristics + _a2lCharacteristicsArray.value = parseResult.characteristicsArray + applyParameterDescriptions() + currentXcpIfData = parseResult.ifData + + xcpDaqDecoder.configureDaqSignals( + signals = overriddenSignals, + rawMeasurements = parseResult.rawMeasurements, + compuMethods = parseResult.compuMethods, + isMsbFirst = parseResult.ifData?.isMsbFirst ?: false + ) + + _uiState.update { + it.copy( + a2lFileName = file.name, + statusMessage = "A2L 로드 완료 (${parseResult.measurements.size}개 Measurements, ${parseResult.characteristics.size}개 Characteristics)" + ) + } + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.SIGNAL_LIST, + title = "Signal list", + defaultWidth = 240f, + defaultHeight = 520f + ) + mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.A2L_MEASUREMENTS, + title = "Measurements", + defaultWidth = 420f, + defaultHeight = 520f + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy(statusMessage = "A2L 로드 실패: ${e.message}") + } + } + } + } + + /** + * A2L Characteristic 단일 특성치 값 튜닝 + */ + fun updateA2lCharacteristicValue(name: String, newValue: Double) { + val target = _a2lCharacteristics.value.find { it.name == name } ?: return + val revision = _workspaceRevision.value + runCalibration("A2L [$name] 쓰기") { + require(newValue.isFinite() && newValue in target.lowerLimit..target.upperLimit) { "값이 허용 범위를 벗어났습니다." } + packetRepository.writeXcpCharacteristic(target.ecuAddress, target.a2lDataType, newValue).calibrationValue() + require(_workspaceRevision.value == revision) { "프로젝트가 변경되었습니다." } + _a2lCharacteristics.update { list -> list.map { if (it.name == name) it.copy(value = newValue) else it } } + } + } + + /** + * A2L Characteristic Array 배열 셀 값 튜닝 + */ + fun updateA2lCharacteristicArrayCell(name: String, cellIndex: Int, newValue: Double) { + val target = _a2lCharacteristicsArray.value.find { it.name == name } ?: return + val revision = _workspaceRevision.value + runCalibration("A2L [$name][$cellIndex] 쓰기") { + require(cellIndex in target.values.indices) { "배열 인덱스가 범위를 벗어났습니다." } + require(newValue.isFinite() && newValue in target.lowerLimit..target.upperLimit) { "값이 허용 범위를 벗어났습니다." } + val address = target.ecuAddress + cellIndex.toLong() * target.a2lDataType.byteSize + packetRepository.writeXcpCharacteristic(address, target.a2lDataType, newValue).calibrationValue() + require(_workspaceRevision.value == revision) { "프로젝트가 변경되었습니다." } + _a2lCharacteristicsArray.update { list -> list.map { arr -> + if (arr.name == name) arr.copy(values = arr.values.toMutableList().also { it[cellIndex] = newValue }) else arr + } } + } + } + + private fun applyProjectOverridesToA2l(signals: List): List { + val proj = _currentProject.value ?: return signals + if (proj.a2lSignalColors.isEmpty() && proj.a2lSignalDescriptions.isEmpty()) return signals + val colorCaseInsensitiveMap = proj.a2lSignalColors.mapKeys { it.key.trim().lowercase(Locale.ROOT) } + val descCaseInsensitiveMap = proj.a2lSignalDescriptions.mapKeys { it.key.trim().lowercase(Locale.ROOT) } + return signals.map { sig -> + val key = sig.name.trim().lowercase(Locale.ROOT) + val customColor = colorCaseInsensitiveMap[key] ?: sig.color + val customDesc = descCaseInsensitiveMap[key] ?: sig.description + sig.copy(color = customColor, description = customDesc) + } + } + + /** + * A2L 신호 Description 수정 및 활성 프로젝트 상태 반영 (알림 없이 자동 저장) + */ + fun updateA2lSignalDescription(context: Context? = null, signalName: String, description: String) { + _a2lSignals.update { list -> + list.map { sig -> + if (sig.name.equals(signalName, ignoreCase = true)) { + sig.copy(description = description) + } else sig + } + } + val currentProj = _currentProject.value ?: com.dct.app.domain.model.project.DpcProject() + val updatedMap = currentProj.a2lSignalDescriptions.toMutableMap() + updatedMap[signalName] = description + _currentProject.value = currentProj.copy(a2lSignalDescriptions = updatedMap) + + if (context != null) { + saveActiveProject(context, showFeedback = false) + } + } + + fun updateA2lSignalDescription(signalName: String, description: String) { + updateA2lSignalDescription(null, signalName, description) + } + + private fun applyProjectOverridesToCan(signals: List): List { + val proj = _currentProject.value ?: return signals + if (proj.canSignalColors.isEmpty() && proj.canSignalDescriptions.isEmpty()) return signals + val colorCaseInsensitiveMap = proj.canSignalColors.mapKeys { it.key.trim().lowercase(Locale.ROOT) } + val descCaseInsensitiveMap = proj.canSignalDescriptions.mapKeys { it.key.trim().lowercase(Locale.ROOT) } + return signals.map { sig -> + val key = sig.name.trim().lowercase(Locale.ROOT) + val customColor = colorCaseInsensitiveMap[key] ?: sig.color + val customDesc = descCaseInsensitiveMap[key] ?: sig.description + sig.copy(color = customColor, description = customDesc) + } + } + + private fun applyProjectOverridesToMdb(database: MdbDatabase): MdbDatabase { + val proj = _currentProject.value ?: return database + if (proj.mdbSignalColors.isEmpty() && proj.mdbSignalDescriptions.isEmpty()) return database + val colorCaseInsensitiveMap = proj.mdbSignalColors.mapKeys { it.key.trim().lowercase(Locale.ROOT) } + val descCaseInsensitiveMap = proj.mdbSignalDescriptions.mapKeys { it.key.trim().lowercase(Locale.ROOT) } + val updatedGroups = database.groups.map { group -> + val updatedSignals = group.signals.map { sig -> + val key = sig.name.trim().lowercase(Locale.ROOT) + val customColor = colorCaseInsensitiveMap[key] ?: sig.color + val customDesc = descCaseInsensitiveMap[key] ?: sig.description + sig.copy(color = customColor, description = customDesc) + } + group.copy(signals = updatedSignals) + } + return database.copy(groups = updatedGroups) + } + + /** + * CAN 신호 Description 수정 및 활성 프로젝트 상태 반영 (알림 없이 자동 저장) + */ + fun updateCanSignalDescription(context: Context? = null, signalName: String, description: String) { + _canSignals.update { list -> + list.map { sig -> + if (sig.name.equals(signalName, ignoreCase = true)) { + sig.copy(description = description) + } else sig + } + } + val currentProj = _currentProject.value ?: com.dct.app.domain.model.project.DpcProject() + val updatedMap = currentProj.canSignalDescriptions.toMutableMap() + updatedMap[signalName] = description + _currentProject.value = currentProj.copy(canSignalDescriptions = updatedMap) + + if (context != null) { + saveActiveProject(context, showFeedback = false) + } + } + + fun updateCanSignalDescription(signalName: String, description: String) { + updateCanSignalDescription(null, signalName, description) + } + + /** + * MDB 신호 Description 수정 및 활성 프로젝트 상태 반영 (알림 없이 자동 저장) + */ + fun updateMdbSignalDescription(context: Context? = null, signalName: String, description: String) { + val currentDb = _uiState.value.mdbDatabase ?: return + val updatedGroups = currentDb.groups.map { group -> + group.copy( + signals = group.signals.map { sig -> + if (sig.name.equals(signalName, ignoreCase = true)) { + sig.copy(description = description) + } else sig + } + ) + } + val updatedDb = currentDb.copy(groups = updatedGroups) + _uiState.update { it.copy(mdbDatabase = updatedDb) } + + val currentProj = _currentProject.value ?: com.dct.app.domain.model.project.DpcProject() + val updatedMap = currentProj.mdbSignalDescriptions.toMutableMap() + updatedMap[signalName] = description + _currentProject.value = currentProj.copy(mdbSignalDescriptions = updatedMap) + + if (context != null) { + saveActiveProject(context, showFeedback = false) + scope.launch(dispatchers.io) { + mdbRepository.updateSignalDescription(context, signalName, description) + } + } + } + + fun updateMdbSignalDescription(signalName: String, description: String) { + updateMdbSignalDescription(null, signalName, description) + } + + /** + * PC 버전의 신호 색상 편집처럼 원본 신호와 이미 열린 모든 표시를 동시에 갱신한다. + * 색상 값은 MDB/DBC/A2L과 동일한 Win32 COLORREF(0x00BBGGRR) 형식으로 유지한다. + */ + fun updateMdbSignalColor(context: Context? = null, signal: SignalDefinition, color: Long) { + val normalizedColor = color and 0xFFFFFFL + val currentDb = _uiState.value.mdbDatabase ?: return + val updatedDb = currentDb.copy( + groups = currentDb.groups.map { group -> + group.copy( + signals = group.signals.map { current -> + if (current.reclid == signal.reclid && current.idx == signal.idx) { + current.copy(color = normalizedColor) + } else current + } + ) + } + ) + _uiState.update { it.copy(mdbDatabase = updatedDb) } + treeViewModel.setDatabase(updatedDb) + + val currentProject = _currentProject.value ?: com.dct.app.domain.model.project.DpcProject() + _currentProject.value = currentProject.copy( + mdbSignalColors = currentProject.mdbSignalColors + (signal.name to normalizedColor) + ) + synchronizeSignalColor("sig_${signal.reclid}_${signal.idx}", signal.name, normalizedColor) + if (context != null) saveActiveProject(context, showFeedback = false) + } + + fun updateCanSignalColor(context: Context? = null, signal: SignalDefinition, color: Long) { + val normalizedColor = color and 0xFFFFFFL + _canSignals.update { signals -> + signals.map { current -> + if (current.idx == signal.idx && current.name.equals(signal.name, ignoreCase = true)) { + current.copy(color = normalizedColor) + } else current + } + } + val currentProject = _currentProject.value ?: com.dct.app.domain.model.project.DpcProject() + _currentProject.value = currentProject.copy( + canSignalColors = currentProject.canSignalColors + (signal.name to normalizedColor) + ) + synchronizeSignalColor("can_${signal.idx}", signal.name, normalizedColor) + if (context != null) saveActiveProject(context, showFeedback = false) + } + + fun updateA2lSignalColor(context: Context? = null, signal: SignalDefinition, color: Long) { + val normalizedColor = color and 0xFFFFFFL + _a2lSignals.update { signals -> + signals.map { current -> + if (current.adl == signal.adl && current.name.equals(signal.name, ignoreCase = true)) { + current.copy(color = normalizedColor) + } else current + } + } + val currentProject = _currentProject.value ?: com.dct.app.domain.model.project.DpcProject() + _currentProject.value = currentProject.copy( + a2lSignalColors = currentProject.a2lSignalColors + (signal.name to normalizedColor) + ) + synchronizeSignalColor("a2l_${signal.adl}", signal.name, normalizedColor) + if (context != null) saveActiveProject(context, showFeedback = false) + } + + private fun synchronizeSignalColor(signalId: String, signalName: String, color: Long) { + fun matches(series: ChartSignalSeries): Boolean { + return series.signalId == signalId || + (series.signalId.equals(signalName, ignoreCase = true) && + series.signalName.equals(signalName, ignoreCase = true)) + } + + fun recolor(series: ChartSignalSeries): ChartSignalSeries = + if (matches(series)) series.copy(color = color) else series + + val renderers = buildList { + add(chartRenderer) + addAll(_windowChartRenderers.values) + } + renderers.forEach { renderer -> + renderer.seriesListFlow.value + .filter(::matches) + .forEach { renderer.setSeriesColor(it.signalId, color) } + } + + fun recolorWindow(window: com.dct.app.presentation.mdi.MdiWindow): com.dct.app.presentation.mdi.MdiWindow { + val updatedSignals = window.config.boundSignals.map(::recolor) + return if (updatedSignals == window.config.boundSignals) window + else window.copy(config = window.config.copy(boundSignals = updatedSignals)) + } + + val mdiState = mdiViewModel.uiState.value + val updatedCurrentWindows = mdiState.windows.map(::recolorWindow) + if (updatedCurrentWindows != mdiState.windows) { + mdiViewModel.setWindows(updatedCurrentWindows, mdiState.activeWindowId) + } + + _pages.update { pages -> + pages.map { page -> + page.copy( + boundSignals = page.boundSignals.map(::recolor), + windows = page.windows.map(::recolorWindow) + ) + } + } + + _uiState.update { state -> + state.copy( + dashboardTiles = state.dashboardTiles.map { tile -> + if (tile.signalId == signalId || + (tile.signalId.equals(signalName, ignoreCase = true) && + tile.signalName.equals(signalName, ignoreCase = true))) { + tile.copy(color = color) + } else tile + } + ) + } + } + + /** + * Formula(수식) 추가 + */ + fun addFormula(formula: com.dct.app.domain.model.formula.FormulaDefinition) { + _formulas.update { it + formula } + _currentProject.update { it?.copy(formulas = _formulas.value) } + _uiState.update { it.copy(statusMessage = "수식 '${formula.name}' 추가 완료") } + } + + /** + * Formula(수식) 수정 + */ + fun updateFormula(formula: com.dct.app.domain.model.formula.FormulaDefinition) { + val previous = _formulas.value.firstOrNull { it.id == formula.id } + _formulas.update { list -> list.map { if (it.id == formula.id) formula else it } } + _currentProject.update { it?.copy(formulas = _formulas.value) } + if (previous != null && previous.color != formula.color) { + synchronizeSignalColor("formula_${previous.name}", previous.name, formula.color) + } + _uiState.update { it.copy(statusMessage = "수식 '${formula.name}' 수정 완료") } + } + + /** + * Formula(수식) 삭제 및 차트 바인딩 정리 + */ + fun deleteFormula(formula: com.dct.app.domain.model.formula.FormulaDefinition) { + _formulas.update { list -> list.filter { it.id != formula.id } } + _currentProject.update { it?.copy(formulas = _formulas.value) } + + // 차트 렌더러에서 해당 수식 신호 제거 + val sigId = "formula_${formula.name}" + for ((_, renderer) in _windowChartRenderers) { + renderer.unbindSignal(sigId) + renderer.unbindSignal(formula.name) + } + chartRenderer.unbindSignal(sigId) + chartRenderer.unbindSignal(formula.name) + _uiState.update { it.copy(statusMessage = "수식 '${formula.name}' 삭제 완료") } + } + + /** + * Sensor Module(센서 모듈) 구성 업데이트 + */ + fun updateSensorModules(modules: List) { + val previousModules = _sensorModules.value + _sensorModules.value = modules + _currentProject.update { it?.copy(sensorModules = modules, smConfigured = true) } + modules.forEach { module -> + val previous = previousModules.firstOrNull { it.moduleIndex == module.moduleIndex } + module.channels.forEach { channel -> + val previousChannel = previous?.channels?.firstOrNull { it.channelIndex == channel.channelIndex } + if (previousChannel != null && previousChannel.color != channel.color) { + val moduleNumber = String.format(Locale.US, "%02d", module.moduleIndex + 1) + val channelNumber = String.format(Locale.US, "%02d", channel.channelIndex + 1) + synchronizeSignalColor( + signalId = "sm_${moduleNumber}_${channelNumber}", + signalName = previousChannel.name, + color = channel.color + ) + } + } + } + _uiState.update { it.copy(statusMessage = "Sensor Module 구성이 저장되었습니다.") } + } + + /** + * MDB Constant 값 수정 및 상태 반영 + */ + fun updateMdbConstantValue(constantName: String, newValue: Double, onComplete: ((String?) -> Unit)? = null, revision: Long = _workspaceRevision.value) { + if (revision != _workspaceRevision.value) { onComplete?.invoke("프로젝트가 변경되었습니다."); return } + val currentDb = _uiState.value.mdbDatabase + val target = currentDb?.findConstant(constantName) + if (currentDb == null || target == null) { onComplete?.invoke("상수를 찾을 수 없습니다."); return } + runCalibration("MDB [$constantName] 쓰기", onComplete) { + require(newValue.isFinite() && newValue in target.min..target.max) { "값이 허용 범위를 벗어났습니다." } + val bytes = com.dct.app.data.protocol.DctCalibrationProtocol.encodeInteger(newValue, target.attr) + packetRepository.writeConstant(target.addr, bytes).calibrationValue() + require(_uiState.value.mdbDatabase === currentDb) { "MDB가 변경되었습니다. 장비에는 쓰기가 반영되었습니다." } + mdbRepository.updateConstant(target.name, newValue).calibrationValue() + require(_uiState.value.mdbDatabase === currentDb) { "MDB가 변경되었습니다." } + _uiState.update { it.copy(mdbDatabase = currentDb.copy(constants = currentDb.constants.map { con -> + if (con.name == target.name) con.copy(data = newValue) else con + })) } + } + } + + /** + * MDB 보정 맵 Read/Write 동작 피드백 (디바이스 연결 상태 검증) + */ + fun readMdbConstant(name: String, onComplete: ((String?) -> Unit)? = null, revision: Long = _workspaceRevision.value) { + if (revision != _workspaceRevision.value) { onComplete?.invoke("프로젝트가 변경되었습니다."); return } + val db = _uiState.value.mdbDatabase + val target = db?.findConstant(name) + if (db == null || target == null) { onComplete?.invoke("상수를 찾을 수 없습니다."); return } + runCalibration("MDB [$name] 읽기", onComplete) { + val bytes = packetRepository.readConstant(target.addr, target.attr.byteSize).calibrationValue() + val value = com.dct.app.data.protocol.DctCalibrationProtocol.decodeInteger(bytes, target.attr) + require(_uiState.value.mdbDatabase === db) { "MDB가 변경되었습니다." } + mdbRepository.updateConstant(target.name, value).calibrationValue() + require(_uiState.value.mdbDatabase === db) { "MDB가 변경되었습니다." } + _uiState.update { it.copy(mdbDatabase = db.copy(constants = db.constants.map { con -> + if (con.name == target.name) con.copy(data = value) else con + })) } + } + } + + fun readA2lCharacteristic(name: String, cellIndex: Int? = null) { + val scalar = _a2lCharacteristics.value.find { it.name == name } + val array = _a2lCharacteristicsArray.value.find { it.name == name } + val type = if (cellIndex == null) scalar?.a2lDataType else array?.a2lDataType + if (type == null) return + val revision = _workspaceRevision.value + val msbFirst = currentXcpIfData?.isMsbFirst ?: false + runCalibration("A2L [$name] 읽기") { + if (cellIndex != null) require(cellIndex in array!!.values.indices) { "배열 인덱스 범위 오류" } + val address = if (cellIndex == null) scalar!!.ecuAddress else array!!.ecuAddress + cellIndex.toLong() * type.byteSize + val bytes = packetRepository.readXcpCharacteristic(address, type).calibrationValue() + require(bytes.size == type.byteSize) { "A2L 응답 길이 오류" } + val value = type.unpackRawValue(bytes, 0, msbFirst) + require(value.isFinite() && _workspaceRevision.value == revision) { "값이 유효하지 않거나 프로젝트가 변경되었습니다." } + if (cellIndex == null) _a2lCharacteristics.update { list -> list.map { if (it.name == name) it.copy(value = value) else it } } + else _a2lCharacteristicsArray.update { list -> list.map { arr -> + if (arr.name == name) arr.copy(values = arr.values.toMutableList().also { it[cellIndex] = value }) else arr + } } + } + } + + fun readCompensationMapX(map: com.dct.app.domain.model.mdb.CompensationMap) { + transferCompensationMap(map, xAxis = true, write = false) + } + + fun readCompensationMapY(map: com.dct.app.domain.model.mdb.CompensationMap) { + transferCompensationMap(map, xAxis = false, write = false) + } + + fun writeCompensationMapX(map: com.dct.app.domain.model.mdb.CompensationMap) { + transferCompensationMap(map, xAxis = true, write = true) + } + + fun writeCompensationMapY(map: com.dct.app.domain.model.mdb.CompensationMap) { + transferCompensationMap(map, xAxis = false, write = true) + } + + private fun transferCompensationMap(map: com.dct.app.domain.model.mdb.CompensationMap, xAxis: Boolean, write: Boolean) { + val db = _uiState.value.mdbDatabase ?: return + runCalibration("보정 ${if (xAxis) "X" else "Y"} ${if (write) "쓰기" else "읽기"}") { + val original = db.compensationMaps.find { it.yName == map.yName && it.type == map.type } + require(original?.copy(points = map.points) == map) { "현재 MDB의 보정 맵이 아닙니다." } + if (write && !xAxis) map.points.forEach { point -> + val raw = if (map.yAttr == MdbAttrType.ATTR_4_UINT32) (point.data.toLong() and 0xFFFFFFFFL).toDouble() else point.data.toDouble() + val value = if (map.isTypeB) raw * map.cvsA + map.cvsB else raw + require(value.isFinite() && value in map.yMin..map.yMax) { "보정 Y값이 허용 범위를 벗어났습니다." } + } + val request = com.dct.app.data.protocol.DctCalibrationProtocol.mapRequest(map, xAxis, write) + val response = packetRepository.sendCommand(request).calibrationValue() ?: error("보정 응답이 없습니다.") + val updated = com.dct.app.data.protocol.DctCalibrationProtocol.mapResponse(map, xAxis, write, response) + require(_uiState.value.mdbDatabase === db) { "MDB가 변경되었습니다. 장비 반영 여부를 확인하세요." } + mdbRepository.updateCompensationMap(map.yName, updated.points).calibrationValue() + require(_uiState.value.mdbDatabase === db) { "MDB가 변경되었습니다." } + _uiState.update { it.copy(mdbDatabase = db.copy(compensationMaps = db.compensationMaps.map { old -> + if (old.yName == map.yName && old.type == map.type) updated else old + })) } + } + } + + /** + * 현재 활성 프로젝트 설정 및 저장 + */ + /** Description metadata only: never sends calibration writes or changes numeric values. */ + fun updateParameterDescription(kind: String, name: String, description: String, revision: Long = _workspaceRevision.value) { + if (revision != _workspaceRevision.value) return + if (calibrationMutex.isLocked) { + _uiState.update { it.copy(statusMessage = "장비 읽기/쓰기 완료 후 설명을 변경하세요.") } + return + } + val project = _currentProject.value ?: return + _currentProject.value = project.copy(parameterDescriptions = project.parameterDescriptions + ("$kind:$name" to description)) + applyParameterDescriptions() + } + + fun updateSensorModuleDescription(moduleIndex: Int, channelIndex: Int, description: String, revision: Long = _workspaceRevision.value) { + if (revision != _workspaceRevision.value) return + updateSensorModules(_sensorModules.value.map { module -> + if (module.moduleIndex == moduleIndex) module.copy(channels = module.channels.map { channel -> + if (channel.channelIndex == channelIndex) channel.copy(description = description) else channel + }) else module + }) + } + + private fun applyParameterDescriptions() { + val descriptions = _currentProject.value?.parameterDescriptions.orEmpty() + _uiState.update { state -> state.copy(mdbDatabase = state.mdbDatabase?.let { db -> + db.copy(constants = db.constants.map { con -> + descriptions["constant:${con.name}"]?.let { con.copy(description = it) } ?: con + }, compensationMaps = db.compensationMaps.map { map -> + descriptions["map_${map.type.name}:${map.yName}"]?.let { map.copy(description = it) } ?: map + }) + }) } + _a2lCharacteristics.update { list -> list.map { item -> + descriptions["characteristic:${item.name}"]?.let { item.copy(description = it) } ?: item + } } + _a2lCharacteristicsArray.update { list -> list.map { item -> + descriptions["array:${item.name}"]?.let { item.copy(description = it) } ?: item + } } + } + + fun setCurrentProject(project: com.dct.app.domain.model.project.DpcProject) { + _currentProject.value = project + applyGraphInterpolationMode(if (project.graphLinear) InterpolationMode.LINEAR else InterpolationMode.PWM_STEP) + + // 1. MDB 처리: 새 프로젝트에 MDB가 포함되어 있지 않으면 이전 MDB 데이터 및 MDB 창 완전 초기화 + val hasMdb = project.mdbFileName.isNotBlank() || !project.localMdbPath.isNullOrBlank() || project.mdbFileUri != null + if (!hasMdb) { + _uiState.update { it.copy(mdbDatabase = null, mdbFileName = null) } + mdiViewModel.closeWindowsByTypes( + MdiWindowType.MDB_SIGNAL_LIST, + MdiWindowType.CONSTANT_SIGNAL_LIST, + MdiWindowType.COMPENSATION_TYPE_A, + MdiWindowType.COMPENSATION_TYPE_B, + MdiWindowType.MDB_TABLE + ) + } else { + _uiState.value.mdbDatabase?.let { currentDb -> + val overriddenDb = applyProjectOverridesToMdb(currentDb) + _uiState.update { it.copy(mdbDatabase = overriddenDb) } + } + } + + // 2. A2L 처리: 새 프로젝트에 A2L이 포함되어 있지 않으면 이전 A2L 데이터 및 A2L 창 완전 초기화 + val hasA2l = project.a2lFileName.isNotBlank() || !project.localA2lPath.isNullOrBlank() || project.a2lFileUri != null + if (!hasA2l) { + _a2lSignals.value = emptyList() + _a2lCharacteristics.value = emptyList() + _a2lCharacteristicsArray.value = emptyList() + currentXcpIfData = null + xcpDaqDecoder.clear() + _uiState.update { it.copy(a2lFileName = null) } + mdiViewModel.closeWindowsByTypes( + MdiWindowType.A2L_MEASUREMENTS, + MdiWindowType.A2L_CHARACTERISTICS, + MdiWindowType.A2L_CHARACTERISTICS_ARRAY, + MdiWindowType.A2L_SIGNAL_LIST + ) + } else { + if (_a2lSignals.value.isNotEmpty()) { + _a2lSignals.value = applyProjectOverridesToA2l(_a2lSignals.value) + } + } + + // 3. CAN DBC 처리: 새 프로젝트에 DBC가 포함되어 있지 않으면 이전 CAN 데이터 및 CAN 창 완전 초기화 + val hasCan = project.canDbcFileName.isNotBlank() || !project.localCanDbcPath.isNullOrBlank() || project.canDbcFileUri != null + if (!hasCan) { + _canSignals.value = emptyList() + _uiState.update { it.copy(canDbcFileName = null) } + mdiViewModel.closeWindowsByTypes(MdiWindowType.CAN_SIGNAL_LIST) + } else { + if (_canSignals.value.isNotEmpty()) { + _canSignals.value = applyProjectOverridesToCan(_canSignals.value) + } + } + + // 4. Formula 처리 + _formulas.value = project.formulas + + // 5. Sensor Module 처리 + _sensorModules.value = if (project.sensorModules.isNotEmpty()) project.sensorModules else com.dct.app.domain.model.project.createDefaultSensorModules() + + clearChartSignals() + + _uiState.update { + it.copy( + projectFileName = project.projectFileName, + mdbFileName = project.mdbFileName.ifEmpty { null }, + canDbcFileName = project.canDbcFileName.ifEmpty { null }, + a2lFileName = project.a2lFileName.ifEmpty { null } + ) + } + applyParameterDescriptions() + } + + fun saveActiveProject(context: Context, targetUri: Uri? = null, showFeedback: Boolean = true): Boolean { + if (isProjectLoading) { + if (showFeedback) setStatusMessage("프로젝트 로딩 완료 후 저장해 주세요.") + return false + } + com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(context) + val defaultBase = com.dct.app.data.repository.DpcProjectManager.getDefaultBaseFolder(context) + val defaultMeasured = com.dct.app.data.repository.DpcProjectManager.getDefaultMeasuredFolder(context) + + var proj = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context) + val rawName = proj.projectFileName.ifEmpty { + _uiState.value.mdbFileName ?: _uiState.value.a2lFileName ?: "PROJECT" + } + val fileName = if (rawName.endsWith(".dpc", ignoreCase = true)) rawName else "$rawName.dpc" + + proj = proj.copy( + projectFileName = fileName, + baseFolder = proj.baseFolder.ifEmpty { defaultBase }, + measuredFilesFolder = proj.measuredFilesFolder.ifEmpty { defaultMeasured }, + copySavedFolder = proj.copySavedFolder.ifEmpty { "$defaultBase/Data" }, + mdbFileName = _uiState.value.mdbFileName ?: proj.mdbFileName, + canDbcFileName = _uiState.value.canDbcFileName ?: proj.canDbcFileName, + a2lFileName = _uiState.value.a2lFileName ?: proj.a2lFileName, + mdbDatabase = _uiState.value.mdbDatabase, + canSignals = _canSignals.value, + a2lSignals = _a2lSignals.value, + formulas = _formulas.value, + sensorModules = _sensorModules.value, + smConfigured = _sensorModules.value.any { it.isUsed }, + workspace = snapshotWorkspaceForSave() + ) + if (targetUri != null) { + proj = proj.copy(projectFileUri = targetUri) + } + _currentProject.value = proj + val saved = com.dct.app.data.repository.DpcProjectManager.saveProject(context, proj) + if (showFeedback) { + if (saved) { + _uiState.update { it.copy(statusMessage = "프로젝트를 저장했습니다") } + } else { + _uiState.update { it.copy(statusMessage = "프로젝트 저장에 실패했습니다.") } + } + } + return saved + } + + fun clearStatusMessage() { + _uiState.update { it.copy(statusMessage = null) } + } + + fun autoSaveOnExit(context: Context): Boolean { + val currentProj = _currentProject.value ?: com.dct.app.domain.model.project.DpcProject() + val hasContent = currentProj.mdbFileName.isNotBlank() || + currentProj.a2lFileName.isNotBlank() || + currentProj.canDbcFileName.isNotBlank() || + currentProj.vehicleCode.isNotBlank() || + _uiState.value.mdbDatabase != null || + _canSignals.value.isNotEmpty() || + _a2lSignals.value.isNotEmpty() || + _formulas.value.isNotEmpty() || + mdiViewModel.uiState.value.windows.isNotEmpty() || + _pages.value.size > 1 || _pages.value.any { it.windows.isNotEmpty() } + + if (!hasContent) return false + + return saveActiveProject(context, showFeedback = false) + } + + fun setStatusMessage(message: String) { + _uiState.update { it.copy(statusMessage = message) } + } + + /** + * 현재 로드된 MDB 및 프로젝트를 원본 Uri와 로컬 저장소로 원자적 영구 저장 + */ + fun saveCurrentMdb(context: Context): DctResult { + val projSaved = saveActiveProject(context, showFeedback = false) + if (_uiState.value.mdbDatabase == null) { + if (projSaved) { + _uiState.update { it.copy(statusMessage = "프로젝트를 저장했습니다") } + } + return DctResult.Success(Unit) + } + scope.launch(dispatchers.io) { + val saveResult = mdbRepository.saveToOriginal(context) + when (saveResult) { + is DctResult.Success -> { + _uiState.update { it.copy(statusMessage = "프로젝트를 저장했습니다") } + } + is DctResult.Failure -> { + _uiState.update { it.copy(statusMessage = "MDB 저장 실패: ${saveResult.error.message}") } + eventBus.emit(AppEvent.ErrorOccurred("MDB 저장 실패: ${saveResult.error.message}", isFatal = false)) + } + is DctResult.Loading -> {} + } + } + return DctResult.Success(Unit) + } + + /** + * MDB 데이터베이스를 OutputStream으로 저장 (호환성 지원) + */ + fun saveCurrentMdb(outputStream: ByteArrayOutputStream = ByteArrayOutputStream()): DctResult { + val db = _uiState.value.mdbDatabase ?: return DctResult.Failure( + com.dct.app.core.error.DctError.Unknown("로드된 데이터베이스가 없습니다.") + ) + return try { + scope.launch(dispatchers.io) { + mdbRepository.saveMdb(db, outputStream) + } + _uiState.update { it.copy(statusMessage = "MDB 데이터베이스가 성공적으로 저장되었습니다.") } + DctResult.Success(Unit) + } catch (e: Exception) { + DctResult.Failure(com.dct.app.core.error.DctError.Unknown(e.message ?: "저장 오류")) + } + } + + /** + * 실시간 계측 토글 (시작 / 정지) - PC ID_MEASURE_START / ID_MEASURE_STOP 규격 + */ + fun toggleMeasure() { + if (_uiState.value.isMeasuring) { + stopMeasure() + } else { + startMeasure() + } + } + + private val activeCsvLoggers = java.util.concurrent.CopyOnWriteArrayList() + private val activeMf4Loggers = java.util.concurrent.CopyOnWriteArrayList() + + /** + * PC C++ 규격: 연결 시점 또는 프로젝트/신호 변경 시 장비 설정을 1회 동기화 주입 + */ + fun syncDeviceConfiguration(onComplete: ((Boolean) -> Unit)? = null) { + configurationRequired = true + val generation = connectionGeneration.get() + scope.launch(dispatchers.io) { + val success = sessionMutex.withLock { + generation == connectionGeneration.get() && syncDeviceConfigurationLocked(generation) + } + onComplete?.invoke(success) + } + } + + private suspend fun syncDeviceConfigurationLocked(generation: Long): Boolean { + if (!packetRepository.isConnected.value && packetRepository.connectionState.value != DeviceConnectionState.READY) { + return false + } + run { + _uiState.update { it.copy(statusMessage = "장비 측정 설정 동기화 중...") } + val configuration = createDeviceMeasurementConfiguration() + val configResult = packetRepository.configureMeasurement(configuration) + if (configResult is DctResult.Failure) { + _uiState.update { it.copy(statusMessage = "장비 설정 실패: ${configResult.error.message}") } + return false + } + + // A2L 신호가 존재하면 XCP on CAN 3단계 설정 시퀀스(0x40 -> 0x41 -> 0x42) 자동 발송 + if (_a2lSignals.value.isNotEmpty()) { + if (reportXcpFailure("XCP 사용 모드", packetRepository.sendXcpUse(0x02))) { + return false + } + if (reportXcpFailure( + "XCP CAN 설정", + packetRepository.sendXcpIfDatas( + currentXcpIfData ?: com.dct.app.data.parser.A2lParser.XcpIfData() + ) + ) + ) { + return false + } + if (reportXcpFailure( + "XCP 측정 목록", + packetRepository.sendXcpMeasurements(_a2lSignals.value) + ) + ) { + return false + } + } + + if (generation != connectionGeneration.get()) return false + configurationRequired = false + _uiState.update { it.copy(statusMessage = "Wi-Fi 장비 연결 및 설정 완료 (Ready)") } + return true + } + } + + /** + * 실시간 계측 시작 (PC OnMeasureStart 호환: 0x20 START_TRANSMIT_DATA 발송, 텔레메트리/차트 공급 및 CSV/MF4 로깅) + */ + fun startMeasure(frequencyHz: Int = 30) { + val generation = connectionGeneration.get() + scope.launch(dispatchers.io) { + sessionMutex.withLock { + if (generation != connectionGeneration.get() || _uiState.value.isMeasuring) return@withLock + if (configurationRequired) { + setStatusMessage("장비 연결 및 설정 완료 후 계측을 시작하세요.") + return@withLock + } + if (packetRepository.connectionState.value != DeviceConnectionState.READY) { + setStatusMessage("Wi-Fi 장비 연결을 먼저 완료하세요.") + return@withLock + } + setStatusMessage("계측 시작 요청 중...") + when (val result = packetRepository.startStreaming(frequencyHz)) { + is DctResult.Success -> { + if (generation == connectionGeneration.get() && + packetRepository.connectionState.value !in setOf(DeviceConnectionState.ERROR, DeviceConnectionState.DISCONNECTED)) { + beginMeasurementSession(frequencyHz) + } + } + is DctResult.Failure -> _uiState.update { it.copy(isMeasuring = false, statusMessage = result.error.message) } + DctResult.Loading -> Unit + } + } + } + } + + private fun reportXcpFailure(step: String, result: DctResult): Boolean { + val failure = result as? DctResult.Failure ?: return false + _uiState.update { it.copy(statusMessage = "$step 실패: ${failure.error.message}") } + return true + } + + private fun createDeviceMeasurementConfiguration(): com.dct.app.domain.model.protocol.DctMeasurementConfiguration { + val project = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() + val maxDlc = if (project.canMode == "CAN(FD)") 64 else 8 + val messages = _canSignals.value + .groupBy { it.messageId } + .map { (canId, signals) -> + val declaredDlc = signals.maxOfOrNull { it.messageDlc } ?: 0 + com.dct.app.domain.model.protocol.DctCanMessageConfiguration(canId, declaredDlc.coerceAtMost(maxDlc)) + } + val mdbSignals = _uiState.value.mdbDatabase?.allSignals.orEmpty() + val selectedMdbSignals = selectVisibleMdbSignals(mdbSignals) + return com.dct.app.domain.model.protocol.DctMeasurementConfiguration( + vehicleCode = project.vehicleCode, + maker = project.maker, + mdbSignals = mdbSignals, + selectedMdbSignals = selectedMdbSignals, + canMessages = messages, + sensorModules = _sensorModules.value, + isHighSpeedCan = project.canMode == "CAN(HiSpeed)", + isOldModel = project.forOldModel + ) + } + + /** PC의 Monitoring 플래그처럼 현재 화면에 실제 바인딩된 MDB 신호만 0x13에 등록한다. */ + private fun selectVisibleMdbSignals( + mdbSignals: List + ): List { + val selectedKeys = mutableSetOf() + fun addSeries(series: com.dct.app.domain.model.chart.ChartSignalSeries) { + selectedKeys += series.signalId + selectedKeys += series.signalName + } + + chartRenderer.seriesListFlow.value.forEach(::addSeries) + _windowChartRenderers.values.forEach { renderer -> + renderer.seriesListFlow.value.forEach(::addSeries) + } + mdiViewModel.uiState.value.windows.forEach { window -> + window.config.boundSignals.forEach(::addSeries) + selectedKeys += window.config.targetSignals + } + _uiState.value.dashboardTiles.forEach { tile -> + selectedKeys += tile.signalId + selectedKeys += tile.signalName + } + + val matched = mdbSignals.filter { signal -> + "sig_${signal.reclid}_${signal.idx}" in selectedKeys || signal.name in selectedKeys + } + // 0x13의 그룹 수와 그룹별 신호 수는 각각 1바이트다. + return matched.groupBy { it.reclid } + .entries + .take(0xFF) + .flatMap { (_, signals) -> signals.distinctBy { it.idx }.take(0xFF) } + } + + /** START ACK 성공 후에만 차트와 파일 로거를 활성화한다. */ + private fun beginMeasurementSession(frequencyHz: Int) { + chartRenderer.resume() + chartRenderLoopJob?.cancel() + chartRenderLoopJob = chartRenderer.start(scope) + for (renderer in _windowChartRenderers.values) { + renderer.resume() + renderer.start(scope) + } + _uiState.update { + it.copy(isMeasuring = true, measurementFrequencyHz = frequencyHz, statusMessage = "실시간 계측 중") + } + + val currentProj = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() + activeCsvLoggers.clear() + activeMf4Loggers.clear() + if (!currentProj.writeCsv && !currentProj.writeMf4) return + val outDir = com.dct.app.data.logger.MeasurementLogPaths.resolve( + currentProj.measuredFilesFolder, currentProj.baseFolder, + currentProj.rawJsonRoot?.optInt("logfile_path_use_basefolder", 0) == 1, + File(com.dct.app.data.repository.DpcProjectManager.getDefaultBaseFolder()) + ) + if (!outDir.exists()) { + outDir.mkdirs() + } + + val rawProjName = currentProj.projectFileName.substringBeforeLast(".dpc").ifBlank { + _uiState.value.mdbFileName?.substringBeforeLast(".") + ?: _uiState.value.a2lFileName?.substringBeforeLast(".") + ?: _uiState.value.canDbcFileName?.substringBeforeLast(".") + ?: "PROJECT" + } + val timeStamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(Date()) + + val mdbSignals = _uiState.value.mdbDatabase?.allSignals?.map { it.name to it.unit } ?: emptyList() + val canSignals = _canSignals.value.map { it.name to it.unit } + val a2lSignals = _a2lSignals.value.map { it.name to it.unit } + + val hasMdb = mdbSignals.isNotEmpty() + val hasCan = canSignals.isNotEmpty() + val hasA2l = a2lSignals.isNotEmpty() + + // 1. MDB 신호 로거 (프로젝트명_날짜_시간_mdb.csv / .mf4) + if (hasMdb) { + val fnCsv = "${rawProjName}_${timeStamp}_mdb.csv" + val fnMf4 = "${rawProjName}_${timeStamp}_mdb.mf4" + if (currentProj.writeCsv) { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, mdbSignals, fnCsv).also { + if (it.start() != null) activeCsvLoggers.add(it) + else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + if (currentProj.writeMf4) { + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, mdbSignals, fnMf4).also { + if (it.start() != null) activeMf4Loggers.add(it) + else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + } + + // 2. CAN DBC 신호 로거 (프로젝트명_날짜_시간_dbc.csv / .mf4) + if (hasCan) { + val fnCsv = "${rawProjName}_${timeStamp}_dbc.csv" + val fnMf4 = "${rawProjName}_${timeStamp}_dbc.mf4" + if (currentProj.writeCsv) { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, canSignals, fnCsv).also { + if (it.start() != null) activeCsvLoggers.add(it) + else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + if (currentProj.writeMf4) { + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, canSignals, fnMf4).also { + if (it.start() != null) activeMf4Loggers.add(it) + else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + } + + // 3. A2L 신호 로거 (PC 레거시 표준: 프로젝트명_날짜_시간_measurement.csv / .mf4) + if (hasA2l) { + val fnCsv = "${rawProjName}_${timeStamp}_measurement.csv" + val fnMf4 = "${rawProjName}_${timeStamp}_measurement.mf4" + if (currentProj.writeCsv) { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, a2lSignals, fnCsv).also { + if (it.start() != null) activeCsvLoggers.add(it) + else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + if (currentProj.writeMf4) { + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, a2lSignals, fnMf4).also { + if (it.start() != null) activeMf4Loggers.add(it) + else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + } + + // 4. Formula 신호 로거 (프로젝트명_날짜_시간_formula.csv / .mf4) + val formulaList = _formulas.value + if (formulaList.isNotEmpty()) { + val formulaSignals = formulaList.map { it.name to it.unit } + val fnCsv = "${rawProjName}_${timeStamp}_formula.csv" + val fnMf4 = "${rawProjName}_${timeStamp}_formula.mf4" + if (currentProj.writeCsv) { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, formulaSignals, fnCsv).also { + if (it.start() != null) activeCsvLoggers.add(it) + else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + if (currentProj.writeMf4) { + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, formulaSignals, fnMf4).also { + if (it.start() != null) activeMf4Loggers.add(it) + else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + } + + // 5. Sensor Module 신호 로거 (프로젝트명_날짜_시간_sm.csv / .mf4) + val activeSmModules = _sensorModules.value.filter { it.isUsed } + if (activeSmModules.isNotEmpty()) { + val smSignals = mutableListOf>() + for (mod in activeSmModules) { + for (ch in mod.channels) { + smSignals.add(ch.name to ch.unit) + } + } + val fnCsv = "${rawProjName}_${timeStamp}_sm.csv" + val fnMf4 = "${rawProjName}_${timeStamp}_sm.mf4" + if (currentProj.writeCsv) { + com.dct.app.data.logger.CsvMeasurementLogger(outDir, smSignals, fnCsv).also { + if (it.start() != null) activeCsvLoggers.add(it) + else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + if (currentProj.writeMf4) { + com.dct.app.data.logger.Mf4MeasurementLogger(outDir, smSignals, fnMf4).also { + if (it.start() != null) activeMf4Loggers.add(it) + else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") + } + } + } + + } + + private fun decodeMdbRecord(recordId: Int, data: ByteArray, output: MutableMap): String? { + val database = _uiState.value.mdbDatabase ?: return "MDB가 로드되지 않았습니다." + return when (val result = com.dct.app.data.protocol.MdbRecordDecoder.decode( + recordId, + data, + database.allSignals + )) { + is com.dct.app.data.protocol.MdbRecordDecoder.DecodeResult.Success -> { + for (decoded in result.signals) { + val signal = decoded.definition + output["sig_${signal.reclid}_${signal.idx}"] = decoded.physicalValue + output[signal.name] = decoded.physicalValue + // Keep the received integer, including all bytes, independently of physical conversion. + output["raw_sig_${signal.reclid}_${signal.idx}"] = decoded.rawValue.toDouble() + } + null + } + is com.dct.app.data.protocol.MdbRecordDecoder.DecodeResult.Failure -> result.reason + } + } + + /** 실제 수신된 바이너리 DctPacket 처리 및 텔레메트리 디코딩. */ + private fun processIncomingPacket(packet: com.dct.app.domain.model.protocol.DctPacket, elapsedSec: Double) { + // 재생(Play) 상태 또는 패킷 리포지토리 스트리밍 중일 때 패킷 처리 + val isMeasuring = _uiState.value.isMeasuring || + packetRepository.isStreaming.value || + packetRepository.connectionState.value == DeviceConnectionState.MEASURING + if (!isMeasuring) { + when (packet.command) { + com.dct.app.domain.model.protocol.DctCommand.RES_START_TRANSMIT_DATA.code, + com.dct.app.domain.model.protocol.DctCommand.SEND_CAN_FRAME.code, + com.dct.app.domain.model.protocol.DctCommand.SEND_SM_DATA.code, + 0xD0.toByte() -> return + } + } + + val payload = packet.payload + val telemetryMap = _liveTelemetryValues.value.toMutableMap() + + when (packet.command) { + com.dct.app.domain.model.protocol.DctCommand.RES_START_TRANSMIT_DATA.code -> { + // 0xA0은 계측 데이터가 아니라 START 명령의 상태 ACK 한 바이트다. + if (payload.firstOrNull() != 0x01.toByte()) { + _uiState.update { it.copy(statusMessage = "장비가 계측 시작을 거절했습니다.") } + } + } + 0x02.toByte() -> { + // 4채널 물리값 호환 패킷 (CH1~CH4 Float) + if (payload.size >= 18) { + val b = java.nio.ByteBuffer.wrap(payload, 2, 16).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val v1 = b.float.toDouble() + val v2 = b.float.toDouble() + val v3 = b.float.toDouble() + val v4 = b.float.toDouble() + + telemetryMap["Incar_Snr"] = v1 + telemetryMap["Amb_Snr"] = v2 + telemetryMap["Speed"] = v3 + telemetryMap["Evap_Snr"] = v4 + telemetryMap["Battery Voltage"] = v1 + telemetryMap["Coolant Temp"] = v2 + telemetryMap["Engine RPM"] = v3 + telemetryMap["Throttle Pos"] = v4 + } + } + com.dct.app.domain.model.protocol.DctCommand.SEND_SM_DATA.code -> { + val modules = com.dct.app.data.protocol.DctStreamPayloadParser.parseSensorStream(payload) + if (modules == null) { + _uiState.update { it.copy(statusMessage = "센서 모듈 수신 데이터가 손상되어 폐기되었습니다.") } + return + } + for (module in modules) { + val configured = _sensorModules.value.firstOrNull { it.moduleIndex == module.moduleIndex } + for (channel in module.channels) { + val signal = configured?.channels?.firstOrNull { it.channelIndex == channel.channelIndex } + val moduleNumber = String.format(java.util.Locale.US, "%02d", module.moduleIndex + 1) + val channelNumber = String.format(java.util.Locale.US, "%02d", channel.channelIndex + 1) + val value = channel.value ?: Double.NaN + telemetryMap["sm_${moduleNumber}_${channelNumber}"] = value + if (signal != null) { + telemetryMap[signal.name] = value + } + } + } + } + com.dct.app.domain.model.protocol.DctCommand.SEND_CAN_FRAME.code -> { + when (val stream = com.dct.app.data.protocol.DctStreamPayloadParser.parseCanStream(payload)) { + is com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload.Mdb -> { + val error = decodeMdbRecord(stream.recordId, stream.data, telemetryMap) + if (error != null) { + _uiState.update { it.copy(statusMessage = "MDB 수신 데이터가 손상되어 폐기되었습니다: $error") } + return + } + } + is com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload.Can -> { + for (frame in stream.frames) { + if (!frame.isReceived) continue + val frameSignals = _canSignals.value.filter { + (it.messageId and 0xFFFFL) == frame.canId + } + val decodedSignals = com.dct.app.data.protocol.CanFrameSignalDecoder.decode( + frame.data, + frameSignals + ) + for (decoded in decodedSignals) { + telemetryMap["can_${decoded.definition.idx}"] = decoded.physicalValue + telemetryMap[decoded.definition.name] = decoded.physicalValue + } + } + } + null -> { + _uiState.update { it.copy(statusMessage = "CAN/MDB 수신 데이터가 손상되어 폐기되었습니다.") } + return + } + } + } + com.dct.app.domain.model.protocol.DctCommand.SEND_XCP_MEASUREMENT_DATA.code -> { + if (xcpDaqDecoder.isConfigured()) { + when (val decoded = xcpDaqDecoder.decodeDaqPayloadResult(payload)) { + is com.dct.app.core.engine.XcpDaqStreamDecoder.DecodeResult.Success -> { + telemetryMap.putAll(decoded.values) + } + is com.dct.app.core.engine.XcpDaqStreamDecoder.DecodeResult.Failure -> { + _uiState.update { + it.copy(statusMessage = "XCP 수신 데이터가 손상되어 폐기되었습니다: ${decoded.reason}") + } + return + } + } + } + } + } + + // Formula 수식 실시간 평가 + val activeFormulas = _formulas.value + if (activeFormulas.isNotEmpty()) { + for (f in activeFormulas) { + val calc = com.dct.app.core.formula.FormulaEvaluator.evaluate(f.expression, telemetryMap) + if (!calc.isNaN()) { + telemetryMap["formula_${f.name}"] = calc + telemetryMap[f.name] = calc + } + } + } + + _liveTelemetryValues.value = telemetryMap + + if (isMeasuring) { + // 1. 모든 개별 윈도우 차트 렌더러에 실측 샘플 공급 (각 윈도우별 독립 차트) + for (renderer in _windowChartRenderers.values) { + val activeSeries = renderer.seriesListFlow.value + for (series in activeSeries) { + val sampleValue = telemetryMap[series.signalId] ?: telemetryMap[series.signalName] + if (sampleValue != null) { + renderer.addSampleSec(series.signalId, elapsedSec, sampleValue) + } + } + } + + // 2. 기본 차트 렌더러에도 샘플 공급 (기존 호환성) + val activeSeries = chartRenderer.seriesListFlow.value + for (series in activeSeries) { + val sampleValue = telemetryMap[series.signalId] ?: telemetryMap[series.signalName] + if (sampleValue != null) { + chartRenderer.addSampleSec(series.signalId, elapsedSec, sampleValue) + } + } + + // 파일 로거에 샘플 기록 (CSV / MF4) + if (activeCsvLoggers.isNotEmpty() || activeMf4Loggers.isNotEmpty()) { + for (logger in activeCsvLoggers) { + val sampleValues = logger.signalHeaders.map { (sigName, _) -> telemetryMap[sigName] } + logger.logSample(elapsedSec, sampleValues) + } + for (logger in activeMf4Loggers) { + val sampleValues = logger.signalHeaders.map { (sigName, _) -> telemetryMap[sigName] } + logger.logSample(elapsedSec, sampleValues) + } + } + } + + // 대시보드 타일 값 업데이트 + _uiState.update { current -> + val updatedTiles = current.dashboardTiles.map { tile -> + val v = telemetryMap[tile.signalId] ?: telemetryMap[tile.signalName] ?: tile.value + if (isMeasuring) { + tile.historyBuffer.addSample(elapsedSec, v) + } + tile.copy(value = v) + } + current.copy( + packetCount = current.packetCount + 1, + dashboardTiles = updatedTiles + ) + } + } + + /** + * 실시간 계측 정지 (PC OnMeasureStop 호환: 0x21 STOP_TRANSMIT_DATA 발송 및 로거 파일 안전 저장/종료) + */ + fun stopMeasure() { + val generation = connectionGeneration.get() + scope.launch(dispatchers.io) { + sessionMutex.withLock { + if (generation != connectionGeneration.get() || !_uiState.value.isMeasuring) return@withLock + setStatusMessage("계측 정지 요청 중...") + when (val result = packetRepository.stopStreaming()) { + is DctResult.Success -> finishMeasurementSession() + is DctResult.Failure -> setStatusMessage("계측 정지 실패: ${result.error.message}") + DctResult.Loading -> Unit + } + } + } + } + + private fun finishMeasurementSession() { + for (r in _windowChartRenderers.values) { + r.stop() + } + chartRenderer.stop() + chartRenderLoopJob?.cancel() + chartRenderLoopJob = null + + for (logger in activeCsvLoggers) { + logger.stop() + } + activeCsvLoggers.clear() + + for (logger in activeMf4Loggers) { + logger.stop() + } + activeMf4Loggers.clear() + + val message = when (packetRepository.connectionState.value) { + DeviceConnectionState.ERROR -> "연결 오류로 계측이 종료되었습니다." + DeviceConnectionState.DISCONNECTED -> "연결 해제로 계측이 종료되었습니다." + else -> "계측 정지됨 - 대기 중 (Ready)" + } + _uiState.update { it.copy(isMeasuring = false, measurementFrequencyHz = 0, statusMessage = message) } + } + + override fun onCleared() { + super.onCleared() + maintenanceService.stopSeedListener() + if (_uiState.value.isMeasuring) finishMeasurementSession() + chartRenderLoopJob?.cancel() + scope.cancel() + } +} + diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/ThreePaneLayoutState.kt b/Android/app/src/main/java/com/dct/app/presentation/main/ThreePaneLayoutState.kt new file mode 100644 index 0000000..0ef87c0 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/main/ThreePaneLayoutState.kt @@ -0,0 +1,69 @@ +package com.dct.app.presentation.main + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * Stitch 3-Pane 태블릿 반응형 레이아웃 상태 홀더 + * + * - Left Pane: 신호 트리 탐색기 (기본 25% 폭) + * - Center Pane: MDI 서브윈도우 워크스페이스 (기본 50% ~ 75% 폭) + * - Right Pane: 인스펙터 & 파라미터 튜너 (기본 25% 폭) + * + * 좌측 및 우측 패널의 표시/접힘(Collapse/Expand) 상태와 비율을 관리합니다. + */ +class ThreePaneLayoutState( + initialLeftVisible: Boolean = true, + initialRightVisible: Boolean = true, + initialLeftWeight: Float = 0.25f, + initialRightWeight: Float = 0.25f +) { + var isLeftPaneVisible by mutableStateOf(initialLeftVisible) + private set + + var isRightPaneVisible by mutableStateOf(initialRightVisible) + private set + + var leftPaneWeight by mutableFloatStateOf(initialLeftWeight.coerceIn(0.15f, 0.40f)) + private set + + var rightPaneWeight by mutableFloatStateOf(initialRightWeight.coerceIn(0.15f, 0.40f)) + private set + + /** + * 중앙 워크스페이스 영역의 동적 가중치 계산 (1.0 - 좌측 - 우측) + */ + val centerPaneWeight: Float + get() { + var remaining = 1.0f + if (isLeftPaneVisible) remaining -= leftPaneWeight + if (isRightPaneVisible) remaining -= rightPaneWeight + return remaining.coerceIn(0.20f, 1.0f) + } + + fun toggleLeftPane() { + isLeftPaneVisible = !isLeftPaneVisible + } + + fun toggleRightPane() { + isRightPaneVisible = !isRightPaneVisible + } + + fun updateLeftPaneVisibility(visible: Boolean) { + isLeftPaneVisible = visible + } + + fun updateRightPaneVisibility(visible: Boolean) { + isRightPaneVisible = visible + } + + fun updateLeftWeight(weight: Float) { + leftPaneWeight = weight.coerceIn(0.15f, 0.40f) + } + + fun updateRightWeight(weight: Float) { + rightPaneWeight = weight.coerceIn(0.15f, 0.40f) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/WorkspaceStateMapper.kt b/Android/app/src/main/java/com/dct/app/presentation/main/WorkspaceStateMapper.kt new file mode 100644 index 0000000..30494d9 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/main/WorkspaceStateMapper.kt @@ -0,0 +1,45 @@ +package com.dct.app.presentation.main + +import com.dct.app.domain.model.WorkspacePageState +import com.dct.app.domain.model.project.* +import com.dct.app.presentation.dashboard.DashboardGridMode +import com.dct.app.presentation.mdi.* + +internal fun WorkspacePageState.toSavedPage() = DpcPageState( + id = id, title = title, windows = windows.map { it.toSavedWindow() }, activeWindowId = activeWindowId, + scrollX = scrollX, scrollY = scrollY, signals = boundSignals.map(DpcSignalBindingState::fromSeries), + alphanumericalSignals = alphanumericalSignals, timeWindowSec = timeWindowSec, timeWindowPreset = timeWindowPreset, + mdbSearchQuery = mdbSearchQuery, canSearchQuery = canSearchQuery, createdAt = createdAt +) + +internal fun DpcPageState.toRuntimePage() = WorkspacePageState( + id = id, title = title, windows = windows.mapNotNull { it.toRuntimeWindow() }, activeWindowId = activeWindowId, + scrollX = scrollX, scrollY = scrollY, boundSignals = signals.map { it.toSeries() }, + alphanumericalSignals = alphanumericalSignals, timeWindowSec = timeWindowSec, timeWindowPreset = timeWindowPreset, + mdbSearchQuery = mdbSearchQuery, canSearchQuery = canSearchQuery, createdAt = createdAt, hasSavedLayout = true +) + +private fun MdiWindow.toSavedWindow() = DpcWindowState( + id = id, type = type.name, title = title, x = offsetX, y = offsetY, width = width, height = height, + zIndex = zIndex, isClosable = isClosable, isMinimized = isMinimized, isMaximized = isMaximized, createdAt = createdAt, + timeWindowPreset = config.timeWindowPreset, dashboardGridMode = config.dashboardGridMode.name, + targetTableName = config.targetTableName, targetParameterName = config.targetParameterName, autoScroll = config.autoScroll, + signals = config.boundSignals.map(DpcSignalBindingState::fromSeries), targetSignals = config.targetSignals, + isTileMode = config.isTileMode, hiddenSignalIds = config.hiddenSignalIds, highlightColors = config.highlightColors, + contentState = config.contentState, chart = config.chartState +) + +private fun DpcWindowState.toRuntimeWindow(): MdiWindow? { + val windowType = MdiWindowType.entries.find { it.name == type } ?: return null + return MdiWindow( + id = id, type = windowType, title = title, offsetX = x, offsetY = y, width = width, height = height, + zIndex = zIndex, isClosable = isClosable, isMinimized = isMinimized, isMaximized = isMaximized, createdAt = createdAt, + config = MdiWindowConfig( + timeWindowPreset = timeWindowPreset, + dashboardGridMode = DashboardGridMode.entries.find { it.name == dashboardGridMode } ?: DashboardGridMode.GRID_3X3, + targetTableName = targetTableName, targetParameterName = targetParameterName, autoScroll = autoScroll, + boundSignals = signals.map { it.toSeries() }, targetSignals = targetSignals, + isTileMode = isTileMode, hiddenSignalIds = hiddenSignalIds, highlightColors = highlightColors, chartState = chart, contentState = contentState + ) + ) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt new file mode 100644 index 0000000..64a9ca3 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt @@ -0,0 +1,317 @@ +package com.dct.app.presentation.maintenance + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.dct.app.data.maintenance.FirmwareFileParser +import com.dct.app.data.maintenance.MaintenanceService +import com.dct.app.domain.model.maintenance.FirmwareImage +import com.dct.app.domain.model.maintenance.RemoteFirmwareFile +import com.dct.app.domain.model.maintenance.RemoteLogFile +import com.dct.app.domain.model.maintenance.SeedKeyState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +@Composable +fun MaintenanceDialog( + service: MaintenanceService, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val progress by service.transferProgress.collectAsState() + val seedState by service.seedKeyState.collectAsState() + var selectedTab by remember { mutableStateOf(0) } + var selectedImage by remember { mutableStateOf(null) } + var firmwareFiles by remember { mutableStateOf>(emptyList()) } + var logFiles by remember { mutableStateOf>(emptyList()) } + var pendingLog by remember { mutableStateOf(null) } + var isBusy by remember { mutableStateOf(false) } + var errorText by remember { mutableStateOf(null) } + var confirmUpload by remember { mutableStateOf(false) } + + val firmwarePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri ?: return@rememberLauncherForActivityResult + scope.launch { + isBusy = true + errorText = null + runCatching { + withContext(Dispatchers.IO) { + val name = queryName(context, uri) ?: "firmware.bin" + val source = context.contentResolver.openInputStream(uri)?.use { it.readBytesLimited(64 * 1024 * 1024) } + ?: throw IllegalArgumentException("펌웨어 파일을 열 수 없습니다.") + FirmwareFileParser.parse(name, source) + } + }.onSuccess { selectedImage = it } + .onFailure { errorText = it.message ?: "펌웨어 파일 분석 실패" } + isBusy = false + } + } + + val logDestination = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> + val file = pendingLog + pendingLog = null + if (uri == null || file == null) return@rememberLauncherForActivityResult + scope.launch { + isBusy = true + errorText = null + val temp = File.createTempFile("dct_log_", ".part", context.cacheDir) + runCatching { + withContext(Dispatchers.IO) { + temp.outputStream().buffered().use { output -> + service.downloadSavedLog(file) { chunk -> output.write(chunk) } + } + context.contentResolver.openOutputStream(uri, "wt")?.use { output -> temp.inputStream().use { it.copyTo(output) } } + ?: throw IllegalStateException("저장 위치를 열 수 없습니다.") + } + }.onFailure { errorText = it.message ?: "로그 다운로드 실패" } + temp.delete() + isBusy = false + } + } + + Dialog( + onDismissRequest = { if (!isBusy && !progress.isRunning) onDismiss() }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.fillMaxSize().padding(24.dp), + shape = MaterialTheme.shapes.large, + tonalElevation = 6.dp + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("장비 유지보수 (P3)", fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + OutlinedButton(onClick = onDismiss, enabled = !isBusy && !progress.isRunning) { Text("닫기") } + } + TabRow(selectedTabIndex = selectedTab) { + listOf("펌웨어", "플래시 로그", "Seed-Key").forEachIndexed { index, title -> + Tab(selected = selectedTab == index, onClick = { selectedTab = index }, text = { Text(title) }) + } + } + if (progress.isRunning || progress.message.isNotBlank()) { + val percent = (progress.fraction * 100).toInt() + Text( + "${progress.message} ${progress.transferredBytes} / ${progress.totalBytes} bytes ($percent%)", + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 10.dp), + fontSize = 12.sp + ) + } + errorText?.let { + Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp)) + } + if (isBusy) { + Row(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } + } + when (selectedTab) { + 0 -> FirmwareTab( + selectedImage = selectedImage, + remoteFiles = firmwareFiles, + enabled = !isBusy && !progress.isRunning, + onPick = { firmwarePicker.launch(arrayOf("application/octet-stream", "text/plain", "*/*")) }, + onRefresh = { + scope.launch { + isBusy = true + errorText = null + runCatching { withContext(Dispatchers.IO) { service.listFirmwareFiles() } } + .onSuccess { firmwareFiles = it } + .onFailure { errorText = it.message } + isBusy = false + } + }, + onUpload = { confirmUpload = true } + ) + 1 -> LogTab( + files = logFiles, + enabled = !isBusy && !progress.isRunning, + onRefresh = { + scope.launch { + isBusy = true + errorText = null + runCatching { withContext(Dispatchers.IO) { service.listSavedLogs() } } + .onSuccess { logFiles = it } + .onFailure { errorText = it.message } + isBusy = false + } + }, + onDownload = { + pendingLog = it + logDestination.launch(safeFileName(it.name)) + } + ) + else -> SeedKeyTab(seedState) + } + } + } + } + + if (confirmUpload) { + AlertDialog( + onDismissRequest = { confirmUpload = false }, + title = { Text("펌웨어 업로드 확인") }, + text = { Text("장비 플래시에 전송할 파일을 다시 확인하십시오. 전송 중 연결을 끊거나 앱을 종료하지 마십시오.\n\n${selectedImage?.fileName.orEmpty()}") }, + confirmButton = { + Button(onClick = { + confirmUpload = false + val image = selectedImage ?: return@Button + scope.launch { + isBusy = true + errorText = null + runCatching { withContext(Dispatchers.IO) { service.uploadFirmware(image) } } + .onFailure { errorText = it.message } + isBusy = false + } + }) { Text("업로드") } + }, + dismissButton = { OutlinedButton(onClick = { confirmUpload = false }) { Text("취소") } } + ) + } +} + +@Composable +private fun FirmwareTab( + selectedImage: FirmwareImage?, + remoteFiles: List, + enabled: Boolean, + onPick: () -> Unit, + onRefresh: () -> Unit, + onUpload: () -> Unit +) { + Column(Modifier.fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onPick, enabled = enabled) { Icon(Icons.Default.FolderOpen, null); Spacer(Modifier.size(6.dp)); Text("HEX/S19/BIN 선택") } + Button(onClick = onUpload, enabled = enabled && selectedImage != null) { Icon(Icons.Default.Upload, null); Spacer(Modifier.size(6.dp)); Text("장비로 업로드") } + OutlinedButton(onClick = onRefresh, enabled = enabled) { Icon(Icons.Default.Refresh, null); Spacer(Modifier.size(6.dp)); Text("장비 목록 새로고침") } + } + selectedImage?.let { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text(it.fileName, fontWeight = FontWeight.SemiBold) + Text("${it.format} · 원본 ${it.sourceLength} bytes · 전송 ${it.bytes.size} bytes · 시작 0x${it.startAddress.toString(16).uppercase()}", fontSize = 12.sp) + } + } + } + Text("장비 저장 펌웨어 (${remoteFiles.size})", fontWeight = FontWeight.SemiBold) + HorizontalDivider() + LazyColumn { items(remoteFiles, key = { it.index }) { Text("${it.index}. ${it.name}", modifier = Modifier.fillMaxWidth().padding(8.dp)) } } + } +} + +@Composable +private fun LogTab( + files: List, + enabled: Boolean, + onRefresh: () -> Unit, + onDownload: (RemoteLogFile) -> Unit +) { + Column(Modifier.fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = onRefresh, enabled = enabled) { Icon(Icons.Default.Refresh, null); Spacer(Modifier.size(6.dp)); Text("장비 로그 목록 새로고침") } + Text("저장 로그 (${files.size})", fontWeight = FontWeight.SemiBold) + HorizontalDivider() + LazyColumn { + items(files, key = { it.index }) { file -> + Row(Modifier.fillMaxWidth().padding(vertical = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Text(file.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + OutlinedButton(onClick = { onDownload(file) }, enabled = enabled) { + Icon(Icons.Default.Download, null); Spacer(Modifier.size(4.dp)); Text("다운로드") + } + } + } + } + } +} + +@Composable +private fun SeedKeyTab(state: SeedKeyState) { + val text = when (state) { + SeedKeyState.Idle -> "Seed 수신 대기 중지" + SeedKeyState.WaitingForSeed -> "장비의 0xB2 Seed를 대기 중입니다." + is SeedKeyState.Authenticated -> "인증 키 전송 완료 (${state.keyLength} bytes)" + is SeedKeyState.Unavailable -> state.reason + is SeedKeyState.Failed -> state.reason + } + Column(Modifier.fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("HKMC ASK 인증 상태", fontWeight = FontWeight.Bold) + Text(text) + Text("실제 인증에는 대상 ABI용 libHKMC_ASK.so와 nativeGenerateKey JNI 심볼이 필요합니다. 라이브러리가 없으면 앱은 키를 추정하지 않고 PC 프로토콜 실패 응답(0x32)을 보냅니다.", fontSize = 12.sp) + } +} + +private fun queryName(context: Context, uri: Uri): String? { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) return cursor.getString(0) + } + return null +} + +private fun safeFileName(value: String): String = value.substringAfterLast('/').substringAfterLast('\\') + .replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "dct_log.bin" } + +private fun java.io.InputStream.readBytesLimited(maxBytes: Int): ByteArray { + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8192) + var total = 0 + while (true) { + val count = read(buffer) + if (count < 0) break + total += count + require(total <= maxBytes) { "펌웨어 원본은 64MB를 초과할 수 없습니다." } + output.write(buffer, 0, count) + } + return output.toByteArray() +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiViewModel.kt b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiViewModel.kt new file mode 100644 index 0000000..46ad4a7 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiViewModel.kt @@ -0,0 +1,513 @@ +package com.dct.app.presentation.mdi + +import androidx.lifecycle.ViewModel +import com.dct.app.core.common.DispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.UUID + +/** + * MDI 워크스페이스 UI 상태 + */ +data class MdiWorkspaceUiState( + val windows: List = emptyList(), + val activeWindowId: String? = null, + val maxWindowsLimit: Int = 10 +) { + val activeWindow: MdiWindow? + get() = windows.find { it.id == activeWindowId } + + val canAddWindow: Boolean + get() = windows.size < maxWindowsLimit +} + +/** + * MDI 서브윈도우 워크스페이스 관리 ViewModel + */ +class MdiViewModel( + private val dispatchers: DispatcherProvider +) : ViewModel() { + + private val _uiState = MutableStateFlow(createInitialState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var windowCounter = 1 + + private fun createInitialState(): MdiWorkspaceUiState { + return MdiWorkspaceUiState( + windows = emptyList(), + activeWindowId = null + ) + } + + /** + * 워크스페이스의 모든 서브윈도우 닫기 및 카운터/상태 초기화 + */ + fun clearAllWindows() { + windowCounter = 1 + _uiState.update { state -> + state.copy( + windows = emptyList(), + activeWindowId = null + ) + } + } + + /** + * 새로운 서브윈도우 탭 생성 + */ + fun addWindow( + type: MdiWindowType, + title: String? = null, + config: MdiWindowConfig = MdiWindowConfig(), + isClosable: Boolean = true, + offsetX: Float = 40f + ((windowCounter % 10) * 16f), + offsetY: Float = 40f + ((windowCounter % 10) * 16f), + width: Float = 420f, + height: Float = 340f + ): String? { + var createdId: String? = null + _uiState.update { state -> + if (state.windows.size >= state.maxWindowsLimit) { + return@update state // 최대 한도 초과 시 무시 + } + + windowCounter++ + val id = "win_${type.name.lowercase()}_${UUID.randomUUID().toString().take(6)}" + val winTitle = title ?: type.defaultTitle + val maxZ = state.windows.maxOfOrNull { it.zIndex } ?: 0f + val newWindow = MdiWindow( + id = id, + type = type, + title = winTitle, + isClosable = isClosable, + offsetX = offsetX, + offsetY = offsetY, + width = width, + height = height, + zIndex = maxZ + 1f, + config = config + ) + createdId = id + state.copy( + windows = state.windows + newWindow, + activeWindowId = id + ) + } + return createdId + } + + /** + * 특정 페이지의 MDI 윈도우 목록 전체 교체 (페이지 전환 시 복원용) + */ + fun setWindows(windows: List, activeId: String? = null) { + // Size-state buttons are no longer exposed. Restore old projects/pages to their + // saved normal bounds so a minimized/maximized window cannot become stranded. + val restoredWindows = windows.map { it.copy(isMinimized = false, isMaximized = false) } + _uiState.update { state -> + state.copy( + windows = restoredWindows, + activeWindowId = activeId ?: restoredWindows.lastOrNull()?.id + ) + } + } + + /** + * 특정 카테고리(MDB Signal, CAN Signal, Formula 등) 서브윈도우를 열거나 기존 윈도우로 포커스 + * 배치 순서: Signal list (16dp) -> MDB Signal (264dp) -> CAN Signal (692dp) -> 기타 (1120dp) + */ + fun openOrFocusCategoryWindow( + type: MdiWindowType, + title: String? = null, + defaultWidth: Float = 420f, + defaultHeight: Float = 520f, + viewportScrollX: Float = 0f, + viewportScrollY: Float = 0f + ): String { + val existing = _uiState.value.windows.find { it.type == type } + if (existing != null) { + bringToFront(existing.id) + // 기존 창이 현재 사용자가 보고 있는 화면 영역 밖으로 크게 벗어나 있다면 현재 화면 안으로 이동 + val isOutOfViewport = (viewportScrollX > 0f || viewportScrollY > 0f) && + (existing.offsetX < viewportScrollX - 100f || + existing.offsetX > viewportScrollX + 1200f || + existing.offsetY < viewportScrollY - 100f || + existing.offsetY > viewportScrollY + 800f) + if (isOutOfViewport) { + updateWindowBounds( + id = existing.id, + offsetX = viewportScrollX + 24f, + offsetY = viewportScrollY + 24f, + width = existing.width, + height = existing.height + ) + } + return existing.id + } + + val targetWidth = if (type == MdiWindowType.SIGNAL_LIST) 240f else defaultWidth + val targetHeight = if (type == MdiWindowType.SIGNAL_LIST) 520f else defaultHeight + + val cascadeCount = _uiState.value.windows.size + val cascadeStep = (cascadeCount % 6) * 20f + + val targetX = if (viewportScrollX > 0f) { + viewportScrollX + 24f + cascadeStep + } else { + when (type) { + MdiWindowType.SIGNAL_LIST -> 16f + MdiWindowType.MDB_SIGNAL_LIST -> 264f + MdiWindowType.CAN_SIGNAL_LIST -> { + val mdbWin = _uiState.value.windows.find { it.type == MdiWindowType.MDB_SIGNAL_LIST } + if (mdbWin != null) (mdbWin.offsetX + mdbWin.width + 8f) else 692f + } + else -> { + val rightMost = _uiState.value.windows.maxOfOrNull { it.offsetX + it.width } ?: 692f + rightMost + 8f + } + } + } + val targetY = if (viewportScrollY > 0f) { + viewportScrollY + 24f + cascadeStep + } else { + 16f + } + + return addWindow( + type = type, + title = title ?: type.defaultTitle, + offsetX = targetX, + offsetY = targetY, + width = targetWidth, + height = targetHeight + ) ?: "" + } + + /** + * 새로운 Graph 윈도우 생성 ("GraphWindow 01", "GraphWindow 02", "GraphWindow 03" 등 프로젝트 전체 탭 무관 전역 순차 번호 자동 부여) + */ + fun addGraphWindow( + defaultWidth: Float = 480f, + defaultHeight: Float = 320f, + viewportScrollX: Float = 0f, + viewportScrollY: Float = 0f, + allProjectWindows: List = emptyList() + ): String { + val allWindowsToCheck = (_uiState.value.windows + allProjectWindows).distinctBy { it.id } + val existingGraphNumbers = allWindowsToCheck + .filter { it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) } + .mapNotNull { win -> + val regex = Regex("""(?:GraphWindow|Graph|차트)\s*(\d+)""", RegexOption.IGNORE_CASE) + val match = regex.find(win.title) + if (match != null) { + match.groupValues[1].toIntOrNull() + } else if (win.title.equals("Graph", ignoreCase = true) || win.title.equals("실시간 다축 차트", ignoreCase = true)) { + 1 + } else null + } + .toSet() + + var nextNum = 1 + while (existingGraphNumbers.contains(nextNum)) { + nextNum++ + } + val graphTitle = String.format(java.util.Locale.US, "GraphWindow %02d", nextNum) + + val graphCount = _uiState.value.windows.count { it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) } + val cascadeStep = (graphCount % 8) * 18f + + val offsetX = if (viewportScrollX > 0f) { + viewportScrollX + 24f + cascadeStep + } else { + val firstGraph = _uiState.value.windows.find { it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) } + val baseOffsetX = firstGraph?.offsetX ?: 280f + baseOffsetX + cascadeStep + } + + val offsetY = if (viewportScrollY > 0f) { + viewportScrollY + 24f + cascadeStep + } else { + val firstGraph = _uiState.value.windows.find { it.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) } + val baseOffsetY = firstGraph?.offsetY ?: 16f + baseOffsetY + cascadeStep + } + + return addWindow( + type = MdiWindowType.GRAPH, + title = graphTitle, + offsetX = offsetX, + offsetY = offsetY, + width = defaultWidth, + height = defaultHeight + ) ?: "" + } + + /** + * 새로운 Alphanumerical 윈도우 생성 ("Alphanumerical Window 01", "Alphanumerical Window 02" 등 프로젝트 전체 탭 무관 전역 순차 번호 자동 부여) + */ + fun addAlphanumericalWindow( + defaultWidth: Float = 380f, + defaultHeight: Float = 320f, + viewportScrollX: Float = 0f, + viewportScrollY: Float = 0f, + allProjectWindows: List = emptyList() + ): String { + val allWindowsToCheck = (_uiState.value.windows + allProjectWindows).distinctBy { it.id } + val existingNumbers = allWindowsToCheck + .filter { it.type == MdiWindowType.ALPHANUMERICAL } + .mapNotNull { win -> + val regex = Regex("""(?:Alphanumerical Window|Alphanumerical)\s*(\d+)""", RegexOption.IGNORE_CASE) + val match = regex.find(win.title) + if (match != null) { + match.groupValues[1].toIntOrNull() + } else if (win.title.equals("Alphanumerical", ignoreCase = true)) { + 1 + } else null + } + .toSet() + + var nextNum = 1 + while (existingNumbers.contains(nextNum)) { + nextNum++ + } + val alphaTitle = String.format(java.util.Locale.US, "Alphanumerical Window %02d", nextNum) + + val alphaCount = _uiState.value.windows.count { it.type == MdiWindowType.ALPHANUMERICAL } + val cascadeStep = (alphaCount % 8) * 18f + + val offsetX = if (viewportScrollX > 0f) { + viewportScrollX + 24f + cascadeStep + } else { + val firstAlpha = _uiState.value.windows.find { it.type == MdiWindowType.ALPHANUMERICAL } + val baseOffsetX = firstAlpha?.offsetX ?: 320f + baseOffsetX + cascadeStep + } + + val offsetY = if (viewportScrollY > 0f) { + viewportScrollY + 24f + cascadeStep + } else { + val firstAlpha = _uiState.value.windows.find { it.type == MdiWindowType.ALPHANUMERICAL } + val baseOffsetY = firstAlpha?.offsetY ?: 20f + baseOffsetY + cascadeStep + } + + return addWindow( + type = MdiWindowType.ALPHANUMERICAL, + title = alphaTitle, + offsetX = offsetX, + offsetY = offsetY, + width = defaultWidth, + height = defaultHeight + ) ?: "" + } + + /** + * 윈도우 위치 및 크기 변경 (절대 좌표) + */ + fun updateWindowBounds(id: String, offsetX: Float, offsetY: Float, width: Float, height: Float) { + _uiState.update { state -> + val updated = state.windows.map { win -> + if (win.id == id) { + win.copy( + offsetX = offsetX.coerceAtLeast(-width + 60f), + offsetY = offsetY.coerceAtLeast(0f), + width = width.coerceIn(120f, 2500f), + height = height.coerceIn(80f, 2000f) + ) + } else win + } + state.copy(windows = updated) + } + } + + /** + * 윈도우 위치 이동 (드래그 델타 기반 원자적 갱신) + */ + fun moveWindow(id: String, dx: Float, dy: Float) { + _uiState.update { state -> + val updated = state.windows.map { win -> + if (win.id == id) { + win.copy( + offsetX = (win.offsetX + dx).coerceAtLeast(-win.width + 60f), + offsetY = (win.offsetY + dy).coerceAtLeast(0f) + ) + } else win + } + state.copy(windows = updated) + } + } + + /** + * 윈도우 크기 변경 (리사이즈 델타 기반 원자적 갱신) + */ + fun resizeWindow(id: String, dw: Float, dh: Float) { + _uiState.update { state -> + val updated = state.windows.map { win -> + if (win.id == id) { + win.copy( + width = (win.width + dw).coerceIn(120f, 2500f), + height = (win.height + dh).coerceIn(80f, 2000f) + ) + } else win + } + state.copy(windows = updated) + } + } + + /** + * 윈도우 최상위 활성화 + */ + fun bringToFront(id: String) { + _uiState.update { state -> + val maxZ = state.windows.maxOfOrNull { it.zIndex } ?: 0f + val updated = state.windows.map { win -> + if (win.id == id) win.copy(zIndex = maxZ + 1f) else win + } + state.copy(windows = updated, activeWindowId = id) + } + } + + fun toggleMinimized(id: String) { + _uiState.update { state -> state.copy(windows = state.windows.map { win -> + if (win.id == id) win.copy(isMinimized = !win.isMinimized, isMaximized = false) else win + }) } + bringToFront(id) + } + + fun toggleMaximized(id: String) { + _uiState.update { state -> state.copy(windows = state.windows.map { win -> + if (win.id == id) win.copy(isMaximized = !win.isMaximized, isMinimized = false) + else win.copy(isMaximized = false) + }) } + bringToFront(id) + } + + /** + * 서브윈도우 닫기 + */ + fun closeWindow(id: String) { + _uiState.update { state -> + val target = state.windows.find { it.id == id } ?: return@update state + if (!target.isClosable) return@update state + + val targetIndex = state.windows.indexOfFirst { it.id == id } + val newWindows = state.windows.filter { it.id != id } + + // 만약 닫힌 창이 활성 창이었다면 인접 창 활성화 + val newActiveId = if (state.activeWindowId == id) { + when { + newWindows.isEmpty() -> null + targetIndex < newWindows.size -> newWindows[targetIndex].id + else -> newWindows.last().id + } + } else { + state.activeWindowId + } + + state.copy( + windows = newWindows, + activeWindowId = newActiveId + ) + } + } + + /** + * 특정 MdiWindowType 목록에 해당하는 서브윈도우들을 일괄 닫기 + */ + fun closeWindowsByTypes(vararg types: MdiWindowType) { + val typeSet = types.toSet() + _uiState.update { state -> + val newWindows = state.windows.filterNot { it.type in typeSet } + val newActiveId = if (state.activeWindowId != null && newWindows.none { it.id == state.activeWindowId }) { + newWindows.lastOrNull()?.id + } else { + state.activeWindowId + } + state.copy(windows = newWindows, activeWindowId = newActiveId) + } + } + + /** + * 활성 서브윈도우 선택 전환 + */ + fun selectWindow(id: String) { + _uiState.update { state -> + if (state.windows.any { it.id == id }) { + state.copy(activeWindowId = id) + } else { + state + } + } + } + + /** + * 서브윈도우 제목 변경 + */ + fun renameWindow(id: String, newTitle: String) { + if (newTitle.isBlank()) return + _uiState.update { state -> + val updated = state.windows.map { win -> + if (win.id == id) win.copy(title = newTitle.trim()) else win + } + state.copy(windows = updated) + } + } + + /** + * 윈도우 설정 변경 + */ + fun updateWindowContentState(id: String, values: Map) { + _uiState.update { state -> state.copy(windows = state.windows.map { win -> + if (win.id == id) win.copy(config = win.config.copy(contentState = values)) else win + }) } + } + + fun updateWindowConfig(id: String, config: MdiWindowConfig) { + _uiState.update { state -> + val updated = state.windows.map { win -> + if (win.id == id) win.copy(config = config) else win + } + state.copy(windows = updated) + } + } + + /** + * 윈도우 순서 이동 (드래그앤드롭 Reorder) + */ + fun reorderWindows(fromIndex: Int, toIndex: Int) { + _uiState.update { state -> + if (fromIndex !in state.windows.indices || toIndex !in state.windows.indices) { + return@update state + } + val mutableList = state.windows.toMutableList() + val item = mutableList.removeAt(fromIndex) + mutableList.add(toIndex, item) + state.copy(windows = mutableList) + } + } + + /** + * 특정 타입의 윈도우가 이미 열려있는지 확인하거나 없으면 새로 생성 후 포커스 + */ + fun getOrCreateWindow( + type: MdiWindowType, + targetKey: String = "", + title: String? = null + ): String { + val existing = _uiState.value.windows.find { + it.type == type && (targetKey.isBlank() || it.config.targetTableName == targetKey || it.config.targetParameterName == targetKey) + } + if (existing != null) { + selectWindow(existing.id) + return existing.id + } + + val config = MdiWindowConfig( + targetTableName = if (type == MdiWindowType.MDB_TABLE) targetKey else "", + targetParameterName = if (type == MdiWindowType.CALIBRATION) targetKey else "" + ) + return addWindow(type = type, title = title, config = config) ?: _uiState.value.activeWindowId ?: "" + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWindow.kt b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWindow.kt new file mode 100644 index 0000000..d83afc8 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWindow.kt @@ -0,0 +1,67 @@ +package com.dct.app.presentation.mdi + +import com.dct.app.domain.model.chart.TimeWindowPreset +import com.dct.app.presentation.dashboard.DashboardGridMode + +/** + * MDI 서브윈도우 타입 + */ +enum class MdiWindowType(val defaultTitle: String, val iconName: String) { + SIGNAL_LIST("Signal list", "list"), + MDB_SIGNAL_LIST("MDB Signal", "list"), + CAN_SIGNAL_LIST("CAN Signal", "list"), + A2L_SIGNAL_LIST("A2L Signal", "list"), + A2L_MEASUREMENTS("Measurements", "list"), + A2L_CHARACTERISTICS("Characteristics", "tune"), + A2L_CHARACTERISTICS_ARRAY("CharacteristicsArray", "table_chart"), + FORMULA_LIST("Formula", "functions"), + CONSTANT_SIGNAL_LIST("Constant Signal", "tune"), + COMPENSATION_TYPE_A("Compensation Type A", "table_chart"), + COMPENSATION_TYPE_B("Compensation Type B", "table_chart"), + SENSOR_MODULE_LIST("Sensor Module", "sensors"), + GRAPH("GraphWindow 01", "bar_chart"), + ALPHANUMERICAL("Alphanumerical", "grid_view"), + MULTI_AXIS_GRAPH("MultiAxisGraph", "open_with"), + CHART("실시간 다축 차트", "show_chart"), + DASHBOARD("타일 대시보드", "dashboard"), + CALIBRATION("캘리브레이션 에디터", "tune"), + MDB_TABLE("MDB 데이터 테이블", "table_chart") +} + +/** + * 윈도우별 커스텀 설정 + */ +data class MdiWindowConfig( + val timeWindowPreset: TimeWindowPreset = TimeWindowPreset.MIN_1, + val dashboardGridMode: DashboardGridMode = DashboardGridMode.GRID_3X3, + val targetTableName: String = "", + val targetParameterName: String = "", + val autoScroll: Boolean = true, + val boundSignals: List = emptyList(), + val targetSignals: List = emptyList(), + val isTileMode: Boolean = true, + val hiddenSignalIds: Set = emptySet(), + val highlightColors: Map = emptyMap(), + val contentState: Map = emptyMap(), + val chartState: com.dct.app.domain.model.project.DpcChartState? = null +) + +/** + * MDI 서브윈도우 인스턴스 엔티티 (위치, 크기, Z-인덱스 지원) + */ +data class MdiWindow( + val id: String, + val type: MdiWindowType, + val title: String, + val isClosable: Boolean = true, + val offsetX: Float = 20f, + val offsetY: Float = 20f, + val width: Float = 360f, + val height: Float = 300f, + val isMinimized: Boolean = false, + val isMaximized: Boolean = false, + val zIndex: Float = 0f, + val config: MdiWindowConfig = MdiWindowConfig(), + val isDirty: Boolean = false, + val createdAt: Long = System.currentTimeMillis() +) diff --git a/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWorkspaceManager.kt b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWorkspaceManager.kt new file mode 100644 index 0000000..aa0c04c --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWorkspaceManager.kt @@ -0,0 +1,313 @@ +package com.dct.app.presentation.mdi + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.dashboard.TileWidgetItem +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import com.dct.app.presentation.calibration.CalibrationInspectorView +import com.dct.app.presentation.calibration.CalibrationViewModel +import com.dct.app.presentation.chart.MultiXGraphView +import com.dct.app.presentation.dashboard.TileSparklineDashboard + +/** + * MDI 멀티 탭 서브윈도우 워크스페이스 컨테이너 (Center Pane) + */ +@Composable +fun MdiWorkspaceManager( + mdiViewModel: MdiViewModel, + chartRenderer: ChartBatchRenderer, + calibrationViewModel: CalibrationViewModel, + dashboardItems: List, + modifier: Modifier = Modifier +) { + val uiState by mdiViewModel.uiState.collectAsState() + var showAddMenu by remember { mutableStateOf(false) } + var renameTargetWindow by remember { mutableStateOf(null) } + var newTitleText by remember { mutableStateOf("") } + + Column( + modifier = modifier + .fillMaxSize() + .background(Color(0xFF0F1218)) + ) { + // 1. MDI 탭 바 + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color(0xFF161922), + shadowElevation = 2.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 스크롤 가능한 탭 목록 + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + uiState.windows.forEach { win -> + val isActive = win.id == uiState.activeWindowId + MdiTabItem( + window = win, + isActive = isActive, + onSelect = { mdiViewModel.selectWindow(win.id) }, + onClose = { mdiViewModel.closeWindow(win.id) }, + onDoubleClick = { + renameTargetWindow = win + newTitleText = win.title + } + ) + } + } + + Spacer(modifier = Modifier.width(6.dp)) + + // + 신규 탭 추가 드롭다운 버튼 + Box { + IconButton( + onClick = { showAddMenu = true }, + enabled = uiState.canAddWindow, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "윈도우 탭 추가", + tint = if (uiState.canAddWindow) Color(0xFF4C8DFF) else Color(0xFF555B6D), + modifier = Modifier.size(18.dp) + ) + } + + DropdownMenu( + expanded = showAddMenu, + onDismissRequest = { showAddMenu = false }, + modifier = Modifier.background(Color(0xFF1E222D)) + ) { + DropdownMenuItem( + text = { Text("실시간 다축 차트 추가", color = Color(0xFFE1E2E8), fontSize = 12.sp) }, + onClick = { + mdiViewModel.addWindow(MdiWindowType.CHART) + showAddMenu = false + } + ) + DropdownMenuItem( + text = { Text("▦ 타일 대시보드 추가", color = Color(0xFFE1E2E8), fontSize = 12.sp) }, + onClick = { + mdiViewModel.addWindow(MdiWindowType.DASHBOARD) + showAddMenu = false + } + ) + DropdownMenuItem( + text = { Text("캘리브레이션 에디터 추가", color = Color(0xFFE1E2E8), fontSize = 12.sp) }, + onClick = { + mdiViewModel.addWindow(MdiWindowType.CALIBRATION) + showAddMenu = false + } + ) + } + } + } + } + + // 2. 활성 서브윈도우 뷰 영역 + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + val activeWin = uiState.activeWindow + if (activeWin == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "열려 있는 윈도우가 없습니다. 상단의 '+' 버튼을 눌러 새 윈도우를 추가하십시오.", + color = Color(0xFF6B7280), + fontSize = 13.sp + ) + } + } else { + when (activeWin.type) { + MdiWindowType.CHART, MdiWindowType.GRAPH, MdiWindowType.MULTI_AXIS_GRAPH -> { + MultiXGraphView( + renderer = chartRenderer, + modifier = Modifier.fillMaxSize() + ) + } + MdiWindowType.DASHBOARD, MdiWindowType.ALPHANUMERICAL -> { + TileSparklineDashboard( + items = dashboardItems, + gridMode = activeWin.config.dashboardGridMode, + modifier = Modifier.fillMaxSize() + ) + } + MdiWindowType.CALIBRATION -> { + CalibrationInspectorView( + viewModel = calibrationViewModel, + modifier = Modifier.fillMaxSize() + ) + } + MdiWindowType.MDB_TABLE, MdiWindowType.SIGNAL_LIST, + MdiWindowType.MDB_SIGNAL_LIST, MdiWindowType.CAN_SIGNAL_LIST, + MdiWindowType.A2L_SIGNAL_LIST, MdiWindowType.A2L_MEASUREMENTS, + MdiWindowType.A2L_CHARACTERISTICS, MdiWindowType.A2L_CHARACTERISTICS_ARRAY, + MdiWindowType.SENSOR_MODULE_LIST, + MdiWindowType.FORMULA_LIST, MdiWindowType.CONSTANT_SIGNAL_LIST, + MdiWindowType.COMPENSATION_TYPE_A, MdiWindowType.COMPENSATION_TYPE_B -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "${activeWin.title}: ${activeWin.config.targetTableName}", + color = Color(0xFFE1E2E8), + fontSize = 14.sp + ) + } + } + } + } + } + } + + // 3. 탭 이름 변경 다이얼로그 + if (renameTargetWindow != null) { + AlertDialog( + onDismissRequest = { renameTargetWindow = null }, + title = { Text("탭 제목 변경", color = Color(0xFFE1E2E8), fontSize = 16.sp) }, + text = { + OutlinedTextField( + value = newTitleText, + onValueChange = { newTitleText = it }, + label = { Text("새 탭 제목", color = Color(0xFF9096A2)) }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF4C8DFF), + unfocusedBorderColor = Color(0xFF2C303E), + focusedTextColor = Color(0xFFE1E2E8), + unfocusedTextColor = Color(0xFFE1E2E8) + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + renameTargetWindow?.let { win -> + mdiViewModel.renameWindow(win.id, newTitleText) + } + renameTargetWindow = null + } + ) { + Text("변경", color = Color(0xFF4C8DFF)) + } + }, + dismissButton = { + TextButton(onClick = { renameTargetWindow = null }) { + Text("취소", color = Color(0xFF9096A2)) + } + }, + containerColor = Color(0xFF1E222D) + ) + } +} + +/** + * MDI 개별 탭 아이템 + */ +@Composable +private fun MdiTabItem( + window: MdiWindow, + isActive: Boolean, + onSelect: () -> Unit, + onClose: () -> Unit, + onDoubleClick: () -> Unit +) { + val tabBackground = if (isActive) Color(0xFF242C3D) else Color(0xFF1A1C24) + val tabBorderColor = if (isActive) Color(0xFF4C8DFF) else Color(0xFF2C303E) + val textColor = if (isActive) Color(0xFFE1E2E8) else Color(0xFF9096A2) + + Surface( + color = tabBackground, + shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, tabBorderColor), + modifier = Modifier + .height(34.dp) + .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) + .clickable { onSelect() } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 10.dp) + ) { + val iconVector = when (window.type) { + MdiWindowType.SIGNAL_LIST, MdiWindowType.MDB_SIGNAL_LIST, MdiWindowType.CAN_SIGNAL_LIST, + MdiWindowType.A2L_SIGNAL_LIST, MdiWindowType.A2L_MEASUREMENTS -> Icons.Default.List + MdiWindowType.A2L_CHARACTERISTICS, MdiWindowType.CONSTANT_SIGNAL_LIST -> Icons.Default.Tune + MdiWindowType.A2L_CHARACTERISTICS_ARRAY -> Icons.Default.TableChart + MdiWindowType.SENSOR_MODULE_LIST -> Icons.Default.Sensors + MdiWindowType.FORMULA_LIST -> Icons.Default.Functions + MdiWindowType.COMPENSATION_TYPE_A, MdiWindowType.COMPENSATION_TYPE_B, MdiWindowType.MDB_TABLE -> Icons.Default.TableChart + MdiWindowType.GRAPH, MdiWindowType.CHART -> Icons.Default.BarChart + MdiWindowType.ALPHANUMERICAL, MdiWindowType.DASHBOARD -> Icons.Default.GridView + MdiWindowType.MULTI_AXIS_GRAPH -> Icons.Default.OpenWith + MdiWindowType.CALIBRATION -> Icons.Default.Tune + } + + Icon( + imageVector = iconVector, + contentDescription = null, + tint = if (isActive) Color(0xFF4C8DFF) else Color(0xFF737685), + modifier = Modifier.size(14.dp) + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = window.title, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = textColor + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (window.isClosable) { + Spacer(modifier = Modifier.width(6.dp)) + IconButton( + onClick = onClose, + modifier = Modifier.size(16.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "닫기", + tint = Color(0xFF9096A2), + modifier = Modifier.size(12.dp) + ) + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/mdi/WindowPageHoverTracker.kt b/Android/app/src/main/java/com/dct/app/presentation/mdi/WindowPageHoverTracker.kt new file mode 100644 index 0000000..83c9458 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/mdi/WindowPageHoverTracker.kt @@ -0,0 +1,32 @@ +package com.dct.app.presentation.mdi + +import androidx.compose.ui.geometry.Offset + +/** Rebase a physical pointer into a page's independently scrolled canvas (pixels -> dp). */ +internal fun windowDragOffset(pointer: Offset, grab: Offset, scroll: Offset, density: Float): Offset { + val relativeY = if (pointer.y < 0f) 0f else pointer.y - grab.y + return Offset( + ((pointer.x - grab.x + scroll.x) / density).coerceAtLeast(0f), + ((relativeY + scroll.y) / density).coerceAtLeast(0f) + ) +} + +/** One activation per uninterrupted stay over a different page tab. */ +class WindowPageHoverTracker(private val dwellMillis: Long = 600L) { + private var target: String? = null + private var enteredAt = 0L + private var activated = false + + fun update(pageId: String?, nowMillis: Long): String? { + if (pageId != target) { + target = pageId + enteredAt = nowMillis + activated = false + } + if (pageId != null && !activated && nowMillis - enteredAt >= dwellMillis) { + activated = true + return pageId + } + return null + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt new file mode 100644 index 0000000..38d7c45 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt @@ -0,0 +1,1341 @@ +package com.dct.app.presentation.monitor + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.OpenableColumns +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.SignalDragDropManager +import kotlinx.coroutines.launch +import kotlinx.coroutines.isActive +import kotlinx.coroutines.delay +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import com.dct.app.presentation.mdi.WindowPageHoverTracker +import com.dct.app.presentation.mdi.windowDragOffset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.core.theme.toComposeColor +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.ChartRenderSnapshot +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.mdi.MdiWindow +import com.dct.app.presentation.dialog.BluetoothDeviceListDialog +import com.dct.app.presentation.dialog.WifiDirectDeviceListDialog +import com.dct.app.presentation.dialog.OpenProjectDialog +import com.dct.app.presentation.dialog.SelectSpecificFileDialog +import com.dct.app.presentation.monitor.components.WorkspaceRibbonBar +import com.dct.app.presentation.monitor.components.WorkspacePageTabBar +import com.dct.app.presentation.monitor.components.MdiSubWindow +import com.dct.app.presentation.monitor.components.WorkspaceTouchNavigation +import com.dct.app.presentation.monitor.components.LocalWorkspaceTouchNavigation +import com.dct.app.presentation.monitor.components.workspaceTouchNavigation +import com.dct.app.presentation.monitor.components.AndroidModernHorizontalScrollbar +import com.dct.app.presentation.monitor.components.AndroidModernVerticalScrollbar +import com.dct.app.presentation.main.CommunicationType +import com.dct.app.presentation.maintenance.MaintenanceDialog +import java.util.Locale + +// Stitch Light Design Tokens +private val ColorBackground = Color(0xFFF8F9FB) +private val ColorSurface = Color(0xFFF8F9FB) +private val ColorSurfaceBright = Color(0xFFF8F9FB) +private val ColorSurfaceContainerLowest = Color(0xFFFFFFFF) +private val ColorSurfaceContainerLow = Color(0xFFF3F4F6) +private val ColorSurfaceContainer = Color(0xFFEDEEF0) +private val ColorSurfaceContainerHigh = Color(0xFFE7E8EA) +private val ColorSurfaceContainerHighest = Color(0xFFE1E2E4) +private val ColorSurfaceVariant = Color(0xFFE1E2E4) +private val ColorPrimary = Color(0xFF003D9B) +private val ColorPrimaryContainer = Color(0xFF0052CC) +private val ColorPrimaryFixedDim = Color(0xFFB2C5FF) +private val ColorOnPrimary = Color(0xFFFFFFFF) +private val ColorOnSurface = Color(0xFF191C1E) +private val ColorOnSurfaceVariant = Color(0xFF434654) +private val ColorOutlineVariant = Color(0xFFC3C6D6) +private val ColorSecondary = Color(0xFF535F73) +private val ColorSecondaryContainer = Color(0xFFD4E0F8) +private val ColorTextPrimary = Color(0xFF1E293B) +private val ColorTextSecondary = Color(0xFF64748B) + +/** + * Diagnostic Data Monitor - AutoLink Pro 메인 모니터링 화면 + * HTML 명세(`project_monitoring_diagnostics_full_screen/code.html`) 및 Stitch UI 규격 100% 충실 이식 + */ +@Composable +fun DiagnosticDataMonitorScreen( + viewModel: MainViewModel, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val configuration = androidx.compose.ui.platform.LocalConfiguration.current + val isPortrait = configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + var lastBackPressTime by remember { mutableStateOf(0L) } + var showNewProjectDialog by remember { mutableStateOf(false) } + var projectDialogTitle by remember { mutableStateOf("New project") } + var showOverwriteConfirmDialog by remember { mutableStateOf(false) } + var pendingConfirmProject by remember { mutableStateOf(null) } + var showOpenProjectDialog by remember { mutableStateOf(false) } + var showPermissionRationaleDialog by remember { mutableStateOf(false) } + var savedProjectFiles by remember { mutableStateOf>(emptyList()) } + var showFileMenu by remember { mutableStateOf(false) } + var showBluetoothDialog by remember { mutableStateOf(false) } + var showWifiDialog by remember { mutableStateOf(false) } + var showMaintenanceDialog by remember { mutableStateOf(false) } + var showExitConfirmDialog by remember { mutableStateOf(false) } + var newProjectState by remember { mutableStateOf(com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context)) } + + // 상단 리본 통합 프로젝트 / MDB / DBC 열기 런처 + val directOpenLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { + try { + val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, flags) + } catch (_: Exception) {} + + val fileName = queryFileName(context, it)?.lowercase() ?: "" + when { + fileName.endsWith(".dpc") -> viewModel.openProjectFromUri(context, it) + fileName.endsWith(".dbc") -> { + val rawName = queryFileName(context, it) ?: "network.dbc" + coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val copiedFile = com.dct.app.data.repository.DpcProjectManager.copyUriToInternalStorage(context, it, rawName) + if (copiedFile != null) { + viewModel.openCanDbcFromFile(copiedFile) + } else { + viewModel.openCanDbcFromUri(context, it, fallbackFileName = rawName) + } + } + } + fileName.endsWith(".a2l") -> { + val rawName = queryFileName(context, it) ?: "ecu.a2l" + coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val copiedFile = com.dct.app.data.repository.DpcProjectManager.copyUriToInternalStorage(context, it, rawName) + if (copiedFile != null) { + viewModel.openA2lFromFile(copiedFile) + } else { + viewModel.openA2lFromUri(context, it, fallbackFileName = rawName) + } + } + } + else -> { + val rawName = queryFileName(context, it) ?: "database.mdb" + coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val copiedFile = com.dct.app.data.repository.DpcProjectManager.copyUriToInternalStorage(context, it, rawName) + if (copiedFile != null) { + viewModel.openMdbFromFile(copiedFile) + } else { + viewModel.openMdbFromUri(context, it, fallbackFileName = rawName) + } + } + } + } + } + } + + // 상단 리본 Save 버튼 전용 프로젝트 저장 피커 (.dpc 생성) + val saveProjectFilePicker = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> + uri?.let { + try { + val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, flags) + } catch (_: Exception) {} + + coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + viewModel.saveActiveProject(context, it) + } + } + } + + // New Project 모달 다이얼로그 전용 파일 피커 런처들 + // New Project 모달 다이얼로그 전용 파일 피커 런처들 (선택 시 파일 생성하지 않고 OK 시 확정 생성) + val modalDpcLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { + try { + val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, flags) + } catch (_: Exception) {} + val name = queryFileName(context, it) ?: "project.dpc" + if (!name.endsWith(".dpc", ignoreCase = true)) { + android.widget.Toast.makeText(context, "선택된 파일이 프로젝트 규격(*.dpc) 파일이 아닙니다.", android.widget.Toast.LENGTH_SHORT).show() + } else { + newProjectState = newProjectState.copy(projectFileName = name, projectFileUri = it) + } + } + } + val modalMdbLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { + try { + val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, flags) + } catch (_: Exception) {} + + val name = queryFileName(context, it) ?: "database.mdb" + if (!name.endsWith(".mdb", ignoreCase = true)) { + android.widget.Toast.makeText(context, "선택된 파일이 MDB 규격(*.mdb) 파일이 아닙니다.", android.widget.Toast.LENGTH_SHORT).show() + } else { + coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val copiedFile = com.dct.app.data.repository.DpcProjectManager.copyUriToInternalStorage(context, it, name) + newProjectState = newProjectState.copy( + mdbFileName = name, + mdbFileUri = it, + localMdbPath = copiedFile?.absolutePath, + a2lFileName = "", + a2lFileUri = null, + localA2lPath = null + ) + } + } + } + } + val modalA2lLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { + try { + val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, flags) + } catch (_: Exception) {} + + val name = queryFileName(context, it) ?: "ecu.a2l" + if (!name.endsWith(".a2l", ignoreCase = true)) { + android.widget.Toast.makeText(context, "선택된 파일이 A2L 규격(*.a2l) 파일이 아닙니다.", android.widget.Toast.LENGTH_SHORT).show() + } else { + coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val copiedFile = com.dct.app.data.repository.DpcProjectManager.copyUriToInternalStorage(context, it, name) + newProjectState = newProjectState.copy( + a2lFileName = name, + a2lFileUri = it, + localA2lPath = copiedFile?.absolutePath, + mdbFileName = "", + mdbFileUri = null, + localMdbPath = null + ) + } + } + } + } + val modalDbcLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { + try { + val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, flags) + } catch (_: Exception) {} + + val name = queryFileName(context, it) ?: "network.dbc" + if (!name.endsWith(".dbc", ignoreCase = true)) { + android.widget.Toast.makeText(context, "선택된 파일이 CAN DBC 규격(*.dbc) 파일이 아닙니다.", android.widget.Toast.LENGTH_SHORT).show() + } else { + coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val copiedFile = com.dct.app.data.repository.DpcProjectManager.copyUriToInternalStorage(context, it, name) + newProjectState = newProjectState.copy( + canDbcFileName = name, + canDbcFileUri = it, + localCanDbcPath = copiedFile?.absolutePath + ) + } + } + } + } + val modalFolderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + uri?.let { + try { + val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, flags) + } catch (_: Exception) {} + val path = it.path ?: "Selected Folder" + newProjectState = newProjectState.copy(measuredFilesFolder = path, measuredFolderUri = it) + } + } + + val mainUiState by viewModel.uiState.collectAsState() + val chartSnapshot by viewModel.chartRenderer.renderSnapshotFlow.collectAsState() + val canSignals by viewModel.canSignals.collectAsState() + val a2lSignals by viewModel.a2lSignals.collectAsState() + val a2lCharacteristics by viewModel.a2lCharacteristics.collectAsState() + val a2lCharacteristicsArray by viewModel.a2lCharacteristicsArray.collectAsState() + val liveTelemetryValues by viewModel.liveTelemetryValues.collectAsState() + val pages by viewModel.pages.collectAsState() + val activePageId by viewModel.activePageId.collectAsState() + val currentProject by viewModel.currentProject.collectAsState() + + val isProjectLoaded = remember(currentProject, mainUiState.mdbDatabase, mainUiState.mdbFileName, mainUiState.a2lFileName, canSignals, a2lSignals) { + val proj = currentProject + (proj != null && (proj.projectFileName.isNotBlank() || proj.mdbFileName.isNotBlank() || proj.a2lFileName.isNotBlank() || proj.canDbcFileName.isNotBlank() || proj.vehicleCode.isNotBlank())) || + mainUiState.mdbDatabase != null || + mainUiState.mdbFileName?.isNotBlank() == true || + mainUiState.a2lFileName?.isNotBlank() == true || + canSignals.isNotEmpty() || + a2lSignals.isNotEmpty() + } + + var mdbSearchQuery by remember { mutableStateOf("") } + var canSearchQuery by remember { mutableStateOf("") } + + val isCommConnected = mainUiState.communicationType != CommunicationType.NONE + val isWifiConnected = mainUiState.communicationType == CommunicationType.WIFI + val isBtConnected = mainUiState.communicationType == CommunicationType.BLUETOOTH + + // 최상위 토스트 메시지 피드백 수신 (팝업창 위 전면 단일 알림) + LaunchedEffect(mainUiState.statusMessage) { + mainUiState.statusMessage?.let { msg -> + if (msg.isNotBlank()) { + android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show() + viewModel.clearStatusMessage() + } + } + } + + val wifiDirectManager = remember { com.dct.app.data.communication.WifiDirectManager(context) } + + // 앱 실행 시 및 Wi-Fi Direct 연결 상태 실시간 동기화 (상대방 연결 감지, 기연결 감지 및 외부 연결 해제 감지) + LaunchedEffect(wifiDirectManager) { + viewModel.registerWifiDirectManager(wifiDirectManager) + wifiDirectManager.registerReceiver() + wifiDirectManager.checkExistingConnection { deviceName, hostIp, isGroupOwner -> + if (mainUiState.communicationType == CommunicationType.NONE) { + viewModel.connectWifi(deviceName, hostIp, 6000, isGroupOwner) + } + } + launch { + wifiDirectManager.connectionInfo.collect { info -> + if (info != null && info.groupFormed) { + if (mainUiState.communicationType == CommunicationType.NONE) { + val isOwner = info.isGroupOwner + val hostIp = if (isOwner) "192.168.49.1" else (info.groupOwnerAddress?.hostAddress ?: "192.168.49.1") + val devName = if (isOwner) "Wi-Fi Direct (Server)" else "Wi-Fi Direct Device" + viewModel.connectWifi(devName, hostIp, 6000, isOwner) + } + } else if (info?.groupFormed != true && mainUiState.communicationType == CommunicationType.WIFI) { + viewModel.disconnectCommunication(context) + } + } + } + } + + + + // MDB 신호 동적 목록 추출 + val mdbSignals = remember(mainUiState.mdbDatabase) { + mainUiState.mdbDatabase?.allSignals ?: emptyList() + } + + val mdiUiState by viewModel.mdiViewModel.uiState.collectAsState() + val density = LocalDensity.current.density + // 페이지마다 독립된 캔버스 스크롤 범위를 유지한다. + // 공용 ScrollState를 사용하면 넓은 페이지의 scroll/maxValue가 작은 페이지의 + // 최소 캔버스 크기에 반영되어 모든 페이지가 같은 크기처럼 동작한다. + val workspaceRevision by viewModel.workspaceRevision.collectAsState() + // Keep the drag session above page-specific scroll state so switching tabs cannot reset it. + var draggingWindowId by remember(workspaceRevision) { mutableStateOf(null) } + var headerGrabOffset by remember(workspaceRevision) { mutableStateOf(Offset.Zero) } + var dragPointerScreen by remember(workspaceRevision) { mutableStateOf(null) } + var dragOrigin by remember(workspaceRevision) { mutableStateOf?>(null) } + var dragHoverPageId by remember(workspaceRevision) { mutableStateOf(null) } + val pageTabBounds = remember { mutableStateMapOf() } + var viewportBoundsInRoot by remember { mutableStateOf(Rect.Zero) } + val pageHoverTracker = remember(draggingWindowId, workspaceRevision) { WindowPageHoverTracker() } + val horizontalScrollStatesByPage = remember(workspaceRevision, density) { mutableMapOf() } + val verticalScrollStatesByPage = remember(workspaceRevision, density) { mutableMapOf() } + val horizontalScrollState = remember(activePageId, workspaceRevision, density) { + horizontalScrollStatesByPage.getOrPut(activePageId) { + ScrollState(((pages.find { it.id == activePageId }?.scrollX ?: 0f) * density).toInt()) + } + } + val verticalScrollState = remember(activePageId, workspaceRevision, density) { + verticalScrollStatesByPage.getOrPut(activePageId) { + ScrollState(((pages.find { it.id == activePageId }?.scrollY ?: 0f) * density).toInt()) + } + } + DisposableEffect(activePageId, workspaceRevision, density) { + val unregister = viewModel.registerPageViewport(activePageId, workspaceRevision) { + horizontalScrollState.value / density to verticalScrollState.value / density + } + onDispose { unregister() } + } + val currentScrollXDp = horizontalScrollState.value / density + val currentScrollYDp = verticalScrollState.value / density + val touchNavigation = remember(activePageId, workspaceRevision) { WorkspaceTouchNavigation() } + val horizontalCanvasFling = ScrollableDefaults.flingBehavior() + val verticalCanvasFling = ScrollableDefaults.flingBehavior() + + LaunchedEffect(pages.map { it.id }) { + val validPageIds = pages.mapTo(mutableSetOf()) { it.id } + horizontalScrollStatesByPage.keys.retainAll(validPageIds) + verticalScrollStatesByPage.keys.retainAll(validPageIds) + } + + val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_STOP || event == androidx.lifecycle.Lifecycle.Event.ON_DESTROY) { + viewModel.autoSaveOnExit(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + viewModel.autoSaveOnExit(context) + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + androidx.activity.compose.BackHandler { + when { + showExitConfirmDialog -> showExitConfirmDialog = false + showNewProjectDialog -> showNewProjectDialog = false + showOpenProjectDialog -> showOpenProjectDialog = false + showBluetoothDialog -> showBluetoothDialog = false + showWifiDialog -> showWifiDialog = false + showOverwriteConfirmDialog -> showOverwriteConfirmDialog = false + showPermissionRationaleDialog -> showPermissionRationaleDialog = false + else -> showExitConfirmDialog = true + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier.fillMaxSize() + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .background(ColorSurfaceBright) + ) { + // 1. Top Title Bar ("DCT" Title + Minimal Project/File Info) + Surface( + color = Color.White, + modifier = Modifier + .fillMaxWidth() + .height(if (isPortrait) 44.dp else 40.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp) + ) { + Text( + text = "DCT", + fontSize = if (isPortrait) 18.sp else 20.sp, + fontWeight = FontWeight.Bold, + color = ColorOnSurface + ) + + val projName = currentProject?.projectFileName?.ifEmpty { null } + ?: mainUiState.projectFileName + val mdbName = mainUiState.mdbFileName ?: currentProject?.mdbFileName?.ifEmpty { null } + val a2lName = mainUiState.a2lFileName ?: currentProject?.a2lFileName?.ifEmpty { null } + val dbcName = mainUiState.canDbcFileName ?: currentProject?.canDbcFileName?.ifEmpty { null } + + val activeFiles = mutableListOf>() + if (!projName.isNullOrBlank()) activeFiles.add("Project" to projName) + if (!mdbName.isNullOrBlank()) activeFiles.add("MDB" to mdbName) + else if (!a2lName.isNullOrBlank()) activeFiles.add("A2L" to a2lName) + if (!dbcName.isNullOrBlank()) activeFiles.add("DBC" to dbcName) + + if (activeFiles.isNotEmpty()) { + Box( + modifier = Modifier + .padding(horizontal = 8.dp) + .height(16.dp) + .width(1.dp) + .background(ColorOutlineVariant) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + activeFiles.forEach { (type, name) -> + Surface( + color = Color(0xFFF8FAFC), + shape = RoundedCornerShape(4.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFE2E8F0)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 8.dp, top = 3.dp, bottom = 3.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // 통일된 색상의 라벨 뱃지 (제목) + Box( + modifier = Modifier + .background(Color(0xFFE2E8F0), shape = RoundedCornerShape(3.dp)) + .padding(horizontal = 5.dp, vertical = 1.5.dp) + ) { + Text( + text = type, + fontSize = 9.5.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF334155), + letterSpacing = 0.3.sp + ) + } + + // 실제 파일명 + Text( + text = name, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + color = Color(0xFF0F172A), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } + } + + // 2. 도구 리본 바 (Tool Ribbon Bar) + WorkspaceRibbonBar( + viewModel = viewModel, + mainUiState = mainUiState, + isProjectLoaded = isProjectLoaded, + isPortrait = isPortrait, + screenWidthDp = configuration.screenWidthDp.toFloat(), + currentScrollXDp = currentScrollXDp, + currentScrollYDp = currentScrollYDp, + onOpenProjectClick = { + if (!isStoragePermissionGranted(context)) { + showPermissionRationaleDialog = true + } + savedProjectFiles = com.dct.app.data.repository.DpcProjectManager.getSavedProjectsInConfiguration(context) + showOpenProjectDialog = true + }, + onSaveProjectClick = { + val currentProj = viewModel.currentProject.value + val hasExistingTarget = currentProj?.projectFileUri != null || !currentProj?.projectFileName.isNullOrBlank() + if (hasExistingTarget) { + viewModel.saveActiveProject(context) + } else { + val defaultName = currentProj?.projectFileName?.ifEmpty { "Project_${System.currentTimeMillis()}.dpc" } ?: "Project_${System.currentTimeMillis()}.dpc" + saveProjectFilePicker.launch(defaultName) + } + }, + onSaveAsProjectClick = { + val currentProj = viewModel.currentProject.value + val defaultName = currentProj?.projectFileName?.ifEmpty { "Project_${System.currentTimeMillis()}.dpc" } ?: "Project_${System.currentTimeMillis()}.dpc" + saveProjectFilePicker.launch(defaultName) + }, + onNewProjectClick = { + projectDialogTitle = "New project" + newProjectState = com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context) + showNewProjectDialog = true + }, + onPropertyProjectClick = { + projectDialogTitle = "Property for project" + currentProject?.let { newProjectState = it } + showNewProjectDialog = true + }, + onWifiClick = { + showWifiDialog = true + }, + onBluetoothClick = { + showBluetoothDialog = true + }, + onMaintenanceClick = { + showMaintenanceDialog = true + }, + onDisconnectClick = { + viewModel.disconnectCommunication(context) + } + ) + + HorizontalDivider(color = ColorOutlineVariant.copy(alpha = 0.5f), thickness = 1.dp) + + // 3. Workspace Page Tabs (동적 멀티 페이지 탭 연동) + WorkspacePageTabBar( + pages = pages, + activePageId = activePageId, + onSwitchPage = { viewModel.switchPage(it) }, + onAddPage = { viewModel.addPage() }, + onRemovePage = { viewModel.removePage(it) }, + dragHoverPageId = dragHoverPageId, + onPageBoundsChanged = { id, bounds -> + if (bounds == null) pageTabBounds.remove(id) else pageTabBounds[id] = bounds + } + ) + + // 4. MDI 워크스페이스 캔버스 (반응형 2D 가로/세로 확장 및 모던 스크롤바) + val mdiUiState by viewModel.mdiViewModel.uiState.collectAsState() + + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color(0xFFDCE1E8)) + .onGloballyPositioned { viewportBoundsInRoot = it.boundsInRoot() } + ) { + val viewportWidthDp = maxWidth + val viewportHeightDp = maxHeight + val viewportWidthPx = constraints.maxWidth.toFloat() + val viewportHeightPx = constraints.maxHeight.toFloat() + + // 드래그 중인 윈도우와 실제 손가락의 화면 좌표를 별도로 추적한다. + // 창 좌표에서 손가락 위치를 역산하면 캔버스 스크롤/좌표 제한 시 두 위치가 분리된다. + + val hasWindows = mdiUiState.windows.isNotEmpty() + val maxWindowRight = if (hasWindows) mdiUiState.windows.maxOf { it.offsetX + it.width } else 0f + val maxWindowBottom = if (hasWindows) mdiUiState.windows.maxOf { it.offsetY + it.height } else 0f + // 창이 없을 때는 화면 크기와 1:1로 일치시켜 설명 가이드 뷰가 화면 정중앙에 정확히 표시되도록 함 + val canvasContentWidthDp = if (hasWindows) { + // 현재 보이는 스크롤 범위보다 캔버스를 작게 만들지 않는다. + // 가장 오른쪽 창을 왼쪽으로 끌 때 content width와 maxScroll이 함께 줄어 + // 화면상 창 이동을 상쇄하던 현상을 방지한다. + maxOf( + viewportWidthDp, + (maxWindowRight + 400f).dp, + viewportWidthDp + currentScrollXDp.dp + ) + } else { + viewportWidthDp + } + val canvasContentHeightDp = if (hasWindows) { + maxOf( + viewportHeightDp, + (maxWindowBottom + 400f).dp, + viewportHeightDp + currentScrollYDp.dp + ) + } else { + viewportHeightDp + } + + LaunchedEffect(hasWindows) { + if (!hasWindows) { + horizontalScrollState.scrollTo(0) + verticalScrollState.scrollTo(0) + } + } + + // 손가락이 화면 모서리에 도달했을 때 가만히 있어도 해당 방향으로 캔버스가 지속적으로 늘어나는 코루틴 루프 (60 FPS) + LaunchedEffect(draggingWindowId, activePageId, workspaceRevision) { + val activeId = draggingWindowId ?: return@LaunchedEffect + while (isActive && draggingWindowId == activeId) { + val win = mdiUiState.windows.find { it.id == activeId } ?: break + val currentScrollX = horizontalScrollState.value + val currentScrollY = verticalScrollState.value + + // 제목줄에서 전달한 실제 포인터 좌표를 사용한다 (페이지/스크롤 보정 포함). + val pointer = dragPointerScreen ?: Offset( + (win.offsetX * density - currentScrollX) + headerGrabOffset.x, + (win.offsetY * density - currentScrollY) + headerGrabOffset.y + ) + val pointerScreenX = pointer.x + val pointerScreenY = pointer.y + + val rootPointer = pointer + viewportBoundsInRoot.topLeft + val tabId = pageTabBounds.entries.firstOrNull { (id, bounds) -> + pages.any { it.id == id } && bounds.contains(rootPointer) + }?.key + val targetId = tabId?.takeUnless { it == activePageId } + dragHoverPageId = targetId + val activatedPage = pageHoverTracker.update(targetId, android.os.SystemClock.uptimeMillis()) + if (activatedPage != null) { + val target = viewModel.pages.value.find { it.id == activatedPage } + if (target != null) { + val targetScrollX = horizontalScrollStatesByPage.getOrPut(target.id) { + ScrollState((target.scrollX * density).toInt()) + }.value + val targetScrollY = verticalScrollStatesByPage.getOrPut(target.id) { + ScrollState((target.scrollY * density).toInt()) + }.value + val placed = windowDragOffset(pointer, headerGrabOffset, + Offset(targetScrollX.toFloat(), targetScrollY.toFloat()), density) + if (viewModel.moveWindowToPageAndSwitch(activeId, target.id, placed.x, placed.y)) { + dragHoverPageId = null + break // Restart with the destination's ScrollStates; retain the header gesture/key. + } + } + } + // A tab dwell must not compete with the canvas's upward edge scrolling. + if (tabId != null || pointerScreenY < 0f) { + delay(16L) + continue + } + + val edgeMarginPx = 36f * density + val autoScrollStep = 16f * density + + var autoScrollX = 0f + var autoScrollY = 0f + + // 1. 손가락이 우측 화면 모서리에 머물러 있음 -> 가만히 있어도 우측으로 캔버스 계속 확장 & 스크롤 + if (pointerScreenX > viewportWidthPx - edgeMarginPx) { + val overflow = pointerScreenX - (viewportWidthPx - edgeMarginPx) + val speed = (overflow / edgeMarginPx).coerceIn(0.2f, 1f) * autoScrollStep + autoScrollX = speed + } + // 2. 손가락이 좌측 화면 모서리(36dp 이내)에 도달했을 때만 좌측으로 스크롤 복귀 (중간 멈춤 원천 차단!) + else if (pointerScreenX < edgeMarginPx && currentScrollX > 0) { + val overflow = edgeMarginPx - pointerScreenX + val speed = (overflow / edgeMarginPx).coerceIn(0.2f, 1f) * autoScrollStep + autoScrollX = -minOf(speed, currentScrollX.toFloat()) + } + + // 3. 손가락이 하단 화면 모서리에 머물러 있음 -> 하단으로 캔버스 계속 확장 & 스크롤 + if (pointerScreenY > viewportHeightPx - edgeMarginPx) { + val overflow = pointerScreenY - (viewportHeightPx - edgeMarginPx) + val speed = (overflow / edgeMarginPx).coerceIn(0.2f, 1f) * autoScrollStep + autoScrollY = speed + } + // 4. 손가락이 상단 화면 모서리에 도달했을 때만 상단으로 스크롤 복귀 + else if (pointerScreenY < edgeMarginPx && currentScrollY > 0) { + val overflow = edgeMarginPx - pointerScreenY + val speed = (overflow / edgeMarginPx).coerceIn(0.2f, 1f) * autoScrollStep + autoScrollY = -minOf(speed, currentScrollY.toFloat()) + } + + if (autoScrollX != 0f || autoScrollY != 0f) { + // 좌표 제한에 걸릴 수 있으므로 요청량이 아니라 실제 창 이동량만큼만 + // 캔버스를 스크롤해 손가락과 창의 상대 위치를 보존한다. + val beforeMove = win + viewModel.mdiViewModel.moveWindow( + activeId, + autoScrollX / density, + autoScrollY / density + ) + val afterMove = viewModel.mdiViewModel.uiState.value.windows.find { it.id == activeId } + ?: break + val appliedScrollX = (afterMove.offsetX - beforeMove.offsetX) * density + val appliedScrollY = (afterMove.offsetY - beforeMove.offsetY) * density + if (appliedScrollX != 0f) { + val targetX = (currentScrollX + appliedScrollX).toInt().coerceAtLeast(0) + horizontalScrollState.scrollTo(targetX) + } + if (appliedScrollY != 0f) { + val targetY = (currentScrollY + appliedScrollY).toInt().coerceAtLeast(0) + verticalScrollState.scrollTo(targetY) + } + } + kotlinx.coroutines.delay(16L) + } + } + + // 윈도우 드래그 이동 (손가락 제스처 1:1 직관적 이동) + val onWindowMove: (MdiWindow, Float, Float) -> Unit = { win, dx, dy -> + if (draggingWindowId == win.id) { + val pointer = dragPointerScreen ?: Offset.Zero + val current = viewModel.mdiViewModel.uiState.value.windows.find { it.id == win.id } + if (current != null) { + val placed = windowDragOffset(pointer, headerGrabOffset, + Offset(horizontalScrollState.value.toFloat(), verticalScrollState.value.toFloat()), density) + viewModel.mdiViewModel.moveWindow(win.id, placed.x - current.offsetX, placed.y - current.offsetY) + } + } else { + viewModel.mdiViewModel.moveWindow(win.id, dx, dy) + } + } + + // 윈도우 크기 조절 시 모서리 자동 스크롤 + val onWindowResizeWithEdgeScroll: (MdiWindow, Float, Float) -> Unit = { win, dw, dh -> + viewModel.mdiViewModel.resizeWindow(win.id, dw, dh) + + val edgeMarginPx = 50f * density + val maxScrollStep = 16f * density + + val newRightPx = (win.offsetX + win.width + dw) * density + val newBottomPx = (win.offsetY + win.height + dh) * density + + val currentScrollX = horizontalScrollState.value + val currentScrollY = verticalScrollState.value + + if (dw > 0f && newRightPx > currentScrollX + viewportWidthPx - edgeMarginPx) { + val overflow = newRightPx - (currentScrollX + viewportWidthPx - edgeMarginPx) + val delta = (overflow / edgeMarginPx * maxScrollStep).coerceIn(2f, maxScrollStep) + coroutineScope.launch { + horizontalScrollState.scrollTo((currentScrollX + delta).toInt().coerceIn(0, horizontalScrollState.maxValue)) + } + } + + if (dh > 0f && newBottomPx > currentScrollY + viewportHeightPx - edgeMarginPx) { + val overflow = newBottomPx - (currentScrollY + viewportHeightPx - edgeMarginPx) + val delta = (overflow / edgeMarginPx * maxScrollStep).coerceIn(2f, maxScrollStep) + coroutineScope.launch { + verticalScrollState.scrollTo((currentScrollY + delta).toInt().coerceIn(0, verticalScrollState.maxValue)) + } + } + } + + CompositionLocalProvider(LocalWorkspaceTouchNavigation provides touchNavigation) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + // 2D (가로/세로) 스크롤 가능한 메인 윈도우 작업 캔버스 + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .workspaceTouchNavigation(touchNavigation, horizontalScrollState, verticalScrollState, + horizontalCanvasFling, verticalCanvasFling) { + draggingWindowId != null || SignalDragDropManager.isDragging + } + .horizontalScroll(horizontalScrollState, flingBehavior = horizontalCanvasFling) + .verticalScroll(verticalScrollState, flingBehavior = verticalCanvasFling) + ) { + Box( + modifier = Modifier + .width(canvasContentWidthDp) + .height(canvasContentHeightDp) + ) { + if (mdiUiState.windows.isEmpty()) { + EmptyCanvasGuideView( + onNewProject = { showNewProjectDialog = true }, + onOpenProject = { + if (!isStoragePermissionGranted(context)) { + showPermissionRationaleDialog = true + } + savedProjectFiles = com.dct.app.data.repository.DpcProjectManager.getSavedProjectsInConfiguration(context) + showOpenProjectDialog = true + } + ) + } else { + mdiUiState.windows.forEach { win -> + key(workspaceRevision, win.id) { + MdiSubWindow( + window = if (win.isMaximized) win.copy( + offsetX = currentScrollXDp, offsetY = currentScrollYDp, + width = viewportWidthDp.value, height = (viewportHeightDp.value - 16f).coerceAtLeast(80f) + ) else win, + viewModel = viewModel, + canSignals = canSignals, + a2lSignals = a2lSignals, + a2lCharacteristics = a2lCharacteristics, + a2lCharacteristicsArray = a2lCharacteristicsArray, + mdbSignals = mdbSignals, + liveTelemetryValues = liveTelemetryValues, + chartSnapshot = chartSnapshot, + density = density, + viewportScrollX = currentScrollXDp, + viewportScrollY = currentScrollYDp, + onClose = { viewModel.mdiViewModel.closeWindow(win.id) }, + onBringToFront = { viewModel.mdiViewModel.bringToFront(win.id) }, + onMove = { dx, dy -> if (!win.isMaximized) onWindowMove(win, dx, dy) }, + onResize = { dw, dh -> if (!win.isMaximized && !win.isMinimized) onWindowResizeWithEdgeScroll(win, dw, dh) }, + onDragStart = { offset -> + if (!win.isMaximized) { + draggingWindowId = win.id + dragOrigin = activePageId to win + headerGrabOffset = offset + dragPointerScreen = Offset( + (win.offsetX * density - horizontalScrollState.value) + offset.x, + (win.offsetY * density - verticalScrollState.value) + offset.y + ) + } + }, + onDragEnd = { + draggingWindowId = null + headerGrabOffset = Offset.Zero + dragPointerScreen = null + dragOrigin = null + dragHoverPageId = null + }, + onDragPointerPosition = { rootPosition -> + if (draggingWindowId == win.id) { + dragPointerScreen = rootPosition - viewportBoundsInRoot.topLeft + } + }, + onDragCancel = { + val origin = dragOrigin + if (origin != null && draggingWindowId == win.id) { + if (viewModel.activePageId.value != origin.first) { + viewModel.moveWindowToPageAndSwitch(win.id, origin.first, + origin.second.offsetX, origin.second.offsetY) + } else { + viewModel.mdiViewModel.updateWindowBounds(win.id, + origin.second.offsetX, origin.second.offsetY, + origin.second.width, origin.second.height) + } + } + draggingWindowId = null + headerGrabOffset = Offset.Zero + dragPointerScreen = null + dragOrigin = null + dragHoverPageId = null + } + ) + } + } + } + } + } + + // 우측 안드로이드 모던 캡슐형 세로 스크롤바 + AndroidModernVerticalScrollbar( + scrollState = verticalScrollState + ) + } + + // 하단 안드로이드 모던 캡슐형 가로 스크롤바 + AndroidModernHorizontalScrollbar( + scrollState = horizontalScrollState + ) + } + } + } + + // 5. Status Bar (Figma 1-553 규격) + Surface( + color = Color(0xFFECEFF3), + modifier = Modifier + .fillMaxWidth() + .height(28.dp) + .border(width = 1.dp, color = ColorOutlineVariant.copy(alpha = 0.5f)) + ) { + val isStatusConnected = isCommConnected || mainUiState.isConnected || mainUiState.connectedDeviceName != null + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Ready", fontSize = 12.sp, color = ColorSecondary) + val commStatusLabel = when { + mainUiState.connectedDeviceName != null -> "Connected (${mainUiState.connectedDeviceName})" + isStatusConnected -> "Connected" + else -> "Disconnected" + } + val statusLedColor = if (isStatusConnected) Color(0xFF10B981) else Color(0xFF9E9E9E) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(statusLedColor) + ) + Text( + text = commStatusLabel, + fontSize = 12.sp, + color = if (isStatusConnected) Color(0xFF1E293B) else ColorSecondary + ) + } + } + + if (isStatusConnected || mainUiState.isMeasuring || mainUiState.packetCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Packets: ${mainUiState.packetCount}", fontSize = 11.sp, color = ColorSecondary) + } + } + } + } + } + + // 드래그 중인 신호의 플로팅 미리보기 배지 (Floating Drag Badge) + if (SignalDragDropManager.isDragging && SignalDragDropManager.draggedSignal != null) { + val dragSig = SignalDragDropManager.draggedSignal!! + val dragPos = SignalDragDropManager.dragPosition + Surface( + color = Color.White, + shape = RoundedCornerShape(4.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF3B82F6)), + shadowElevation = 8.dp, + modifier = Modifier + .offset { IntOffset((dragPos.x - 20).toInt(), (dragPos.y - 45).toInt()) } + .zIndex(9999f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(dragSig.colorLong.toComposeColor(), RoundedCornerShape(2.dp)) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "${if (dragSig.isCan) "[CAN]" else if (dragSig.isA2l) "[A2L]" else "[MDB]"} ${dragSig.signalName}", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1E293B) + ) + } + } + } + } + + // Figma node 1:740 New Project / Property for project 모달 다이얼로그 + val applyAndSaveProject: (com.dct.app.domain.model.project.DpcProject) -> Unit = { proj -> + showNewProjectDialog = false + if (projectDialogTitle == "New project") { + viewModel.resetWorkspaceForNewProject() + } + viewModel.setCurrentProject(proj) + // 1. .dpc 프로젝트 파일 저장 (Documents/DCT Work/Configuration/ 단일 경로) + viewModel.saveActiveProject(context, showFeedback = false) + // 2. MDB 파일 로드 + if (!proj.localMdbPath.isNullOrBlank() && java.io.File(proj.localMdbPath).exists()) { + viewModel.openMdbFromFile(java.io.File(proj.localMdbPath)) + } else if (proj.mdbFileUri != null) { + viewModel.openMdbFromUri(context, proj.mdbFileUri, fallbackFileName = proj.mdbFileName) + } + // 3. A2L 파일 로드 + if (!proj.localA2lPath.isNullOrBlank() && java.io.File(proj.localA2lPath).exists()) { + viewModel.openA2lFromFile(java.io.File(proj.localA2lPath)) + } else if (proj.a2lFileUri != null) { + viewModel.openA2lFromUri(context, proj.a2lFileUri, fallbackFileName = proj.a2lFileName) + } + // 4. CAN DBC 파일 로드 + if (!proj.localCanDbcPath.isNullOrBlank() && java.io.File(proj.localCanDbcPath).exists()) { + viewModel.openCanDbcFromFile(java.io.File(proj.localCanDbcPath)) + } else if (proj.canDbcFileUri != null) { + viewModel.openCanDbcFromUri(context, proj.canDbcFileUri, fallbackFileName = proj.canDbcFileName) + } + } + + // Figma node 1:740 New Project / Property for project 모달 다이얼로그 + if (showNewProjectDialog) { + com.dct.app.presentation.project.NewProjectDialog( + dialogTitle = projectDialogTitle, + project = newProjectState, + onProjectChange = { newProjectState = it }, + onDismiss = { showNewProjectDialog = false }, + onConfirm = { proj -> + val rawName = proj.projectFileName.ifEmpty { + mainUiState.mdbFileName ?: mainUiState.a2lFileName ?: "PROJECT" + } + val targetFileName = if (rawName.endsWith(".dpc", ignoreCase = true)) rawName else "$rawName.dpc" + val fileAlreadyExists = com.dct.app.data.repository.DpcProjectManager.isProjectFileExists(context, targetFileName) + + // 신규 프로젝트 생성 시 이미 동일한 파일이 Configuration에 존재하는 경우 덮어쓰기 확인 팝업 (방안 1) + if (fileAlreadyExists && projectDialogTitle == "New project") { + pendingConfirmProject = proj + showOverwriteConfirmDialog = true + } else { + applyAndSaveProject(proj) + } + }, + onPickProjectFile = { modalDpcLauncher.launch(arrayOf("application/octet-stream", "*/*")) }, + onPickMdbFile = { modalMdbLauncher.launch(arrayOf("application/x-msaccess", "application/msaccess", "application/vnd.ms-access", "application/octet-stream", "*/*")) }, + onPickA2lFile = { modalA2lLauncher.launch(arrayOf("text/plain", "application/octet-stream", "*/*")) }, + onPickDbcFile = { modalDbcLauncher.launch(arrayOf("text/plain", "application/octet-stream", "*/*")) }, + onPickFolder = { modalFolderLauncher.launch(null) } + ) + } + + // 동일한 프로젝트 파일 존재 시 덮어쓰기 확인 다이얼로그 (방안 1) + if (showOverwriteConfirmDialog && pendingConfirmProject != null) { + val proj = pendingConfirmProject!! + val rawName = proj.projectFileName.ifEmpty { + mainUiState.mdbFileName ?: mainUiState.a2lFileName ?: "PROJECT" + } + val targetFileName = if (rawName.endsWith(".dpc", ignoreCase = true)) rawName else "$rawName.dpc" + + AlertDialog( + onDismissRequest = { + showOverwriteConfirmDialog = false + pendingConfirmProject = null + }, + icon = { + Icon( + imageVector = Icons.Default.WarningAmber, + contentDescription = null, + tint = Color(0xFFD97706), + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = "프로젝트 덮어쓰기 확인", + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + }, + text = { + Text( + text = "Configuration 폴더에 이미 '$targetFileName' 파일이 존재합니다.\n\n기존 파일을 새로운 프로젝트 내용으로 덮어쓰시겠습니까?", + fontSize = 13.sp, + color = Color(0xFF475569) + ) + }, + confirmButton = { + Button( + onClick = { + showOverwriteConfirmDialog = false + applyAndSaveProject(proj) + pendingConfirmProject = null + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) + ) { + Text("덮어쓰기", fontSize = 13.sp) + } + }, + dismissButton = { + OutlinedButton( + onClick = { + showOverwriteConfirmDialog = false + pendingConfirmProject = null + showNewProjectDialog = true + } + ) { + Text("취소 (이름 변경)", fontSize = 13.sp) + } + } + ) + } + + // Documents/DCT Work/Configuration 프로젝트 파일 열기 다이얼로그 + if (showOpenProjectDialog) { + OpenProjectDialog( + projectFiles = savedProjectFiles, + onSelectFile = { file -> + showOpenProjectDialog = false + viewModel.openProjectFromFile(context, file) + }, + onBrowseOther = { + showOpenProjectDialog = false + directOpenLauncher.launch(arrayOf("*/*")) + }, + onDismiss = { showOpenProjectDialog = false } + ) + } + + // Bluetooth 장치 선택 다이얼로그 + if (showBluetoothDialog) { + BluetoothDeviceListDialog( + currentConnectedDevice = if (isBtConnected) mainUiState.connectedDeviceName else null, + onSelectDevice = { deviceName -> viewModel.connectBluetooth(deviceName) }, + onDismiss = { showBluetoothDialog = false } + ) + } + + // Wi-Fi Direct 및 TCP 소켓 연결 다이얼로그 + if (showWifiDialog) { + WifiDirectDeviceListDialog( + manager = wifiDirectManager, + currentConnectedDevice = if (isWifiConnected) mainUiState.connectedDeviceName else null, + onSelectDevice = { deviceName, hostIp, port -> + val isOwner = wifiDirectManager.connectionInfo.value?.isGroupOwner == true + viewModel.connectWifi(deviceName, hostIp, port, isOwner) + }, + onDismiss = { showWifiDialog = false } + ) + } + + if (showMaintenanceDialog) { + MaintenanceDialog( + service = viewModel.maintenanceService, + onDismiss = { showMaintenanceDialog = false } + ) + } + + // 저장소 접근 권한 필요 안내 다이얼로그 + if (showPermissionRationaleDialog) { + AlertDialog( + onDismissRequest = { showPermissionRationaleDialog = false }, + icon = { + Icon( + imageVector = Icons.Default.FolderSpecial, + contentDescription = null, + tint = Color(0xFF2563EB), + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = "저장소 접근 권한 필요", + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + }, + text = { + Text( + text = "Documents/DCT Work 폴더에 프로젝트(.dpc) 및 계측 데이터(Data, Log)를 저장하고 열람하려면 파일 접근 권한이 필요합니다.\n\n[권한 설정]을 눌러 '모든 파일에 대한 접근'을 허용해 주세요.", + fontSize = 13.sp, + color = Color(0xFF475569) + ) + }, + confirmButton = { + Button( + onClick = { + showPermissionRationaleDialog = false + requestAppStoragePermission(context) + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2563EB)) + ) { + Text("권한 설정", fontSize = 13.sp) + } + }, + dismissButton = { + OutlinedButton(onClick = { showPermissionRationaleDialog = false }) { + Text("닫기", fontSize = 13.sp) + } + } + ) + } + + // 프로그램 종료 확인 팝업 다이얼로그 + if (showExitConfirmDialog) { + AlertDialog( + onDismissRequest = { showExitConfirmDialog = false }, + title = { + Text( + text = "프로그램 종료", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = Color(0xFF0F172A) + ) + }, + text = { + Text( + text = "DCT 프로그램을 종료하시겠습니까?", + fontSize = 14.sp, + color = Color(0xFF334155) + ) + }, + confirmButton = { + Button( + onClick = { + showExitConfirmDialog = false + viewModel.autoSaveOnExit(context) + (context as? android.app.Activity)?.finish() + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) + ) { + Text("종료", color = Color.White, fontSize = 13.sp) + } + }, + dismissButton = { + OutlinedButton(onClick = { showExitConfirmDialog = false }) { + Text("취소", color = Color(0xFF64748B), fontSize = 13.sp) + } + } + ) + } +} + +/** + * 저장소 쓰기 / 모든 파일 관리 권한 확인 헬퍼 + */ +private fun isStoragePermissionGranted(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + } +} + +/** + * 시스템 설정 저장소 권한 허용 화면 이동 헬퍼 + */ +private fun requestAppStoragePermission(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: Exception) { + try { + val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: Exception) {} + } + } else { + try { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: Exception) {} + } +} + +private fun queryFileName(context: Context, uri: Uri): String? { + var result: String? = null + if (uri.scheme == "content") { + try { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx >= 0) { + result = cursor.getString(idx) + } + } + } + } catch (_: Exception) {} + } + if (result.isNullOrBlank() || result!!.startsWith("msf:") || result!!.startsWith("raw:")) { + val lastSeg = uri.lastPathSegment ?: uri.path + result = lastSeg?.replace('\\', '/')?.substringAfterLast('/') + } + return result +} + + + diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt new file mode 100644 index 0000000..2921dae --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt @@ -0,0 +1,389 @@ +package com.dct.app.presentation.monitor + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.NoteAdd +import androidx.compose.material.icons.filled.OpenWith +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.DashboardCustomize +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * 아무 창도 열려있지 않은 빈 MDI 캔버스에 표시되는 상단 리본 메뉴 아이콘 기반 사용 방법 안내 가이드 뷰 + */ +@Composable +fun EmptyCanvasGuideView( + onNewProject: () -> Unit, + onOpenProject: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + color = Color(0xFFF8FAFC).copy(alpha = 0.98f), + shape = RoundedCornerShape(8.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFCBD5E1)), + shadowElevation = 3.dp, + modifier = Modifier + .widthIn(max = 620.dp) + .fillMaxWidth(0.94f) + .padding(12.dp) + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 1. 헤더 (아이콘 + 타이틀) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Outlined.DashboardCustomize, + contentDescription = null, + tint = Color(0xFF2563EB), + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "DCT", + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1E293B), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle(includeFontPadding = false) + ) + ) + } + + Text( + text = "시작하려면 상단 리본 메뉴의 도구를 사용하거나 새 프로젝트를 생성해 주세요.", + fontSize = 12.sp, + color = Color(0xFF64748B), + modifier = Modifier.padding(top = 4.dp, bottom = 14.dp), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle(includeFontPadding = false) + ) + ) + + // 구분선 + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color(0xFFE2E8F0)) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 2. 3단계 리본 아이콘 연계 가이드 + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Step 1: 프로젝트 관리 + GuideStepCard( + stepNumber = "1", + title = "프로젝트 열기 및 생성", + description = "상단 리본 메뉴의 파일 도구로 .dpc 프로젝트, MDB, CAN DBC, A2L 파일을 불러옵니다.", + chips = { + Row( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RibbonIconChip( + icon = Icons.Default.NoteAdd, + label = "New Project", + tint = Color(0xFF1E293B), + bgColor = Color(0xFFF1F5F9), + borderColor = Color(0xFFCBD5E1) + ) + RibbonIconChip( + icon = Icons.Default.FolderOpen, + label = "Open", + tint = Color(0xFF1E293B), + bgColor = Color(0xFFF1F5F9), + borderColor = Color(0xFFCBD5E1) + ) + RibbonIconChip( + icon = Icons.Default.Save, + label = "Save", + tint = Color(0xFF1E293B), + bgColor = Color(0xFFF1F5F9), + borderColor = Color(0xFFCBD5E1) + ) + } + } + ) + + // Step 2: 윈도우 생성 및 신호 바인딩 + GuideStepCard( + stepNumber = "2", + title = "윈도우 추가 및 신호 계측 바인딩", + description = "상단 리본에서 원하는 창을 생성하고, 신호 목록에서 신호를 길게 눌러(Long Press) 드래그 앤 드롭(Drag & Drop)합니다.", + chips = { + Row( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RibbonIconChip( + icon = Icons.Default.BarChart, + label = "Graph", + tint = Color(0xFF1D4ED8), + bgColor = Color(0xFFEFF6FF), + borderColor = Color(0xFFBFDBFE) + ) + RibbonIconChip( + icon = Icons.Default.GridView, + label = "Alphanumerical", + tint = Color(0xFF1B62A5), + bgColor = Color(0xFFF0F7FF), + borderColor = Color(0xFFBAE6FD) + ) + RibbonIconChip( + icon = Icons.Default.OpenWith, + label = "MultiAxisGraph", + tint = Color(0xFF4338CA), + bgColor = Color(0xFFEEF2FF), + borderColor = Color(0xFFC7D2FE) + ) + } + } + ) + + // Step 3: 통신 및 계측 제어 + GuideStepCard( + stepNumber = "3", + title = "통신 연결 및 계측 시작/정지", + description = "Wi-Fi Direct 또는 Bluetooth로 차량/ECU와 연결한 후 계측을 시작합니다.", + chips = { + Row( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RibbonIconChip( + icon = Icons.Default.Wifi, + label = "Wi-Fi Direct", + tint = Color(0xFF2563EB), + bgColor = Color(0xFFEFF6FF), + borderColor = Color(0xFFBFDBFE) + ) + RibbonIconChip( + icon = Icons.Default.Bluetooth, + label = "Bluetooth", + tint = Color(0xFF2563EB), + bgColor = Color(0xFFEFF6FF), + borderColor = Color(0xFFBFDBFE) + ) + RibbonIconChip( + icon = Icons.Default.PlayArrow, + label = "Start", + tint = Color(0xFF15803D), + bgColor = Color(0xFFF0FDF4), + borderColor = Color(0xFFBBF7D0) + ) + RibbonIconChip( + icon = Icons.Default.Stop, + label = "Stop", + tint = Color(0xFFB91C1C), + bgColor = Color(0xFFFEF2F2), + borderColor = Color(0xFFFECACA) + ) + } + } + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + // 3. 퀵 액션 단축 버튼들 + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + QuickActionButton( + icon = Icons.Default.Add, + label = "새 프로젝트", + onClick = onNewProject + ) + QuickActionButton( + icon = Icons.Default.FolderOpen, + label = "프로젝트 열기", + onClick = onOpenProject + ) + } + } + } + } +} + +@Composable +private fun RibbonIconChip( + icon: ImageVector, + label: String, + tint: Color, + bgColor: Color, + borderColor: Color +) { + Surface( + color = bgColor, + shape = RoundedCornerShape(4.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, borderColor) + ) { + Row( + modifier = Modifier.padding(horizontal = 7.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = tint, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = label, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + color = tint, + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } +} + +@Composable +private fun GuideStepCard( + stepNumber: String, + title: String, + description: String, + chips: @Composable () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(Color(0xFFE2E8F0)), + contentAlignment = Alignment.Center + ) { + Text( + text = stepNumber, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF334155), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1E293B), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + Text( + text = description, + fontSize = 11.sp, + color = Color(0xFF64748B), + lineHeight = 16.sp, + modifier = Modifier.padding(top = 2.dp), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + chips() + } + } +} + +@Composable +private fun QuickActionButton( + icon: ImageVector, + label: String, + onClick: () -> Unit +) { + Surface( + color = Color.White, + shape = RoundedCornerShape(4.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFCBD5E1)), + modifier = Modifier.clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color(0xFF475569), + modifier = Modifier.size(15.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = label, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF334155), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphaSignal.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphaSignal.kt new file mode 100644 index 0000000..7ffd3ea --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphaSignal.kt @@ -0,0 +1,133 @@ +package com.dct.app.presentation.monitor.components + +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.formula.FormulaDefinition +import com.dct.app.domain.model.project.DpcSensorModuleConfig +import java.util.Locale + +data class AlphaSignal(val id: String, val definition: SignalDefinition) { + fun value(values: Map): String { + val v = values[id] ?: values[definition.name] + if (id.startsWith("sm_") && (v == null || !v.isFinite() || v == -999999.0)) return "NC" + if (v == null || !v.isFinite()) return "N/A" + val number = when { + id.startsWith("sm_") -> String.format(Locale.US, "%.2f", v) + id.startsWith("formula_") -> String.format(Locale.US, "%.3f", v) + id.startsWith("a2l_") -> definition.formatMeasurementValue(v) + else -> definition.formatDisplayValue(v) + } + return if (definition.unit.isBlank()) number else "$number ${definition.unit}" + } +} + +fun resolveAlphaSignals( + ids: List, can: List, mdb: List, + a2l: List, formulas: List, + modules: List +): List = ids.mapNotNull { id -> + val sig = findSignalById(id, can, mdb, a2l, formulas, modules) ?: return@mapNotNull null + val key = when { + id.startsWith("formula_") -> "formula_${sig.name}" + id.startsWith("sm_") -> String.format(Locale.US, "sm_%02d_%02d", sig.reclid + 1, sig.idx + 1) + id.startsWith("a2l_") -> "a2l_${sig.adl}" + id.startsWith("can_") -> "can_${sig.idx}" + id.startsWith("sig_") || id.startsWith("mdb_") -> "sig_${sig.reclid}_${sig.idx}" + can.contains(sig) -> "can_${sig.idx}" + a2l.contains(sig) -> "a2l_${sig.adl}" + mdb.contains(sig) -> "sig_${sig.reclid}_${sig.idx}" + else -> "formula_${sig.name}" + } + AlphaSignal(key, sig) +}.distinctBy { it.id } + +private fun findSignalById( + targetId: String, + canSignals: List, + mdbSignals: List, + a2lSignals: List, + formulas: List = emptyList(), + sensorModules: List = emptyList() +): SignalDefinition? { + if (targetId.startsWith("formula_")) { + val fName = targetId.removePrefix("formula_") + val f = formulas.find { it.name.equals(fName, ignoreCase = true) } + if (f != null) { + return SignalDefinition( + reclid = 0, + idx = 0, + name = f.name, + attr = MdbAttrType.ATTR_0_UINT8, + cvsA = 1.0, + cvsB = 0.0, + unit = f.unit, + color = f.color, + min = 0.0, + max = 100.0 + ) + } + } + if (targetId.startsWith("sm_")) { + for (mod in sensorModules) { + val moduleNumber = String.format(Locale.US, "%02d", mod.moduleIndex + 1) + val ch = mod.channels.find { + val channelNumber = String.format(Locale.US, "%02d", it.channelIndex + 1) + "sm_${moduleNumber}_${channelNumber}" == targetId || + "sm_${mod.moduleIndex}_${it.channelIndex}" == targetId || + it.name.equals(targetId.removePrefix("sm_"), ignoreCase = true) + } + if (ch != null) { + return SignalDefinition( + reclid = mod.moduleIndex, + idx = ch.channelIndex, + name = ch.name, + attr = MdbAttrType.ATTR_0_UINT8, + cvsA = 1.0, + cvsB = 0.0, + unit = ch.unit, + color = ch.color, + min = 0.0, + max = 100.0 + ) + } + } + } + if (targetId.startsWith("can_")) { + val numStr = targetId.removePrefix("can_").takeWhile { it.isDigit() } + val idx = numStr.toIntOrNull() + return canSignals.find { "can_${it.idx}" == targetId || "can_${it.idx}_${it.name}" == targetId } + ?: (if (idx != null) canSignals.find { it.idx == idx } else null) + ?: canSignals.find { it.name.equals(targetId.removePrefix("can_"), ignoreCase = true) } + } + if (targetId.startsWith("a2l_")) { + val numStr = targetId.removePrefix("a2l_").takeWhile { it.isDigit() } + val adl = numStr.toIntOrNull() + return a2lSignals.find { "a2l_${it.adl}" == targetId } + ?: (if (adl != null) a2lSignals.find { it.adl == adl } else null) + ?: a2lSignals.find { it.name.equals(targetId.removePrefix("a2l_"), ignoreCase = true) } + } + if (targetId.startsWith("sig_") || targetId.startsWith("mdb_")) { + val raw = targetId.removePrefix("sig_").removePrefix("mdb_") + val parts = raw.split("_") + val byExact = mdbSignals.find { "sig_${it.reclid}_${it.idx}" == targetId || "mdb_sig_${it.reclid}_${it.idx}" == targetId } + if (byExact != null) return byExact + if (parts.size >= 2) { + val r = parts[0].toIntOrNull() + val i = parts[1].toIntOrNull() + if (r != null && i != null && (r > 0 || i > 0)) { + val byReclIdx = mdbSignals.find { it.reclid == r && it.idx == i } + if (byReclIdx != null) return byReclIdx + } + } + val byIdx = parts.firstOrNull()?.toIntOrNull()?.let { idx -> mdbSignals.find { it.idx == idx } } + if (byIdx != null) return byIdx + val byName = mdbSignals.find { it.name.equals(raw, ignoreCase = true) } + if (byName != null) return byName + } + return canSignals.find { it.name.equals(targetId, ignoreCase = true) } + ?: mdbSignals.find { it.name.equals(targetId, ignoreCase = true) } + ?: a2lSignals.find { it.name.equals(targetId, ignoreCase = true) } + ?: formulas.find { it.name.equals(targetId, ignoreCase = true) }?.let { f -> + SignalDefinition(reclid = 0, idx = 0, name = f.name, attr = MdbAttrType.ATTR_0_UINT8, cvsA = 1.0, cvsB = 0.0, unit = f.unit, color = f.color, min = 0.0, max = 100.0) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphanumericalWindowView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphanumericalWindowView.kt new file mode 100644 index 0000000..6c7f541 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphanumericalWindowView.kt @@ -0,0 +1,670 @@ +@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) + +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.core.theme.toComposeColor +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.SignalDragDropManager +import java.util.Locale + +// PC Classic Visual Theme Color Tokens (Blue Header + Gray Content) +private val AlphaLightBg = Color(0xFFF0F4F8) +private val AlphaValueBg = Color(0xFF6B7280) // Classic DCT Gray Value Area +private val AlphaCardBorder = Color(0xFFCBD5E1) +private val AlphaValueText = Color.White +private val AlphaDragHighlightBg = Color(0x183B82F6) +private val AlphaDragBorder = Color(0xFF2563EB) + +/** + * Alphanumerical Window 콘텐츠 컴포넌트 + */ +@Composable +fun AlphanumericalWindowContent( + windowId: String, + pinnedSignalIds: List, + liveTelemetryValues: Map, + canSignals: List, + mdbSignals: List, + a2lSignals: List = emptyList(), + formulas: List = emptyList(), + sensorModules: List = emptyList(), + isTileMode: Boolean = true, + hiddenSignalIds: Set = emptySet(), + highlightColors: Map = emptyMap(), + onAddSignal: (String) -> Unit, + onRemoveSignal: (String) -> Unit, + onClearAll: (() -> Unit)? = null, + onSignalDrop: ((DraggedSignal) -> Unit)? = null +) { + val isDragging = SignalDragDropManager.isDragging + + DisposableEffect(windowId) { + onDispose { SignalDragDropManager.unregisterDropTarget(windowId) } + } + + val displaySignals = resolveAlphaSignals(pinnedSignalIds, canSignals, mdbSignals, a2lSignals, formulas, sensorModules) + val unresolvedIds = pinnedSignalIds.filter { id -> + resolveAlphaSignals(listOf(id), canSignals, mdbSignals, a2lSignals, formulas, sensorModules).isEmpty() + } + val visibleSignals = displaySignals.filterNot { + it.id in hiddenSignalIds || it.definition.name in hiddenSignalIds + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(AlphaLightBg) + .onGloballyPositioned { coords -> + SignalDragDropManager.registerDropTarget(windowId, coords.boundsInRoot()) + } + .then(if (isDragging) Modifier.border(2.dp, AlphaDragBorder) else Modifier) + ) { + if (displaySignals.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.GridView, + contentDescription = null, + tint = if (isDragging) Color(0xFF2563EB) else Color(0xFFCBD5E1), + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (isDragging) "Drop signal here" + else "Drag signals from list and drop here", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = if (isDragging) Color(0xFF2563EB) else Color(0xFF64748B), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Real-time values will be displayed in tile grid / table", + fontSize = 10.sp, + color = Color(0xFF94A3B8), + textAlign = TextAlign.Center + ) + } + } + } else if (isTileMode) { + LazyVerticalGrid( + columns = GridCells.FixedSize(120.dp), + modifier = Modifier + .fillMaxSize() + .padding(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + itemsIndexed(visibleSignals, key = { _, entry -> entry.id }) { _, entry -> + val sig = entry.definition + val hlColor = highlightColors[entry.id] ?: highlightColors[sig.name] ?: 0L + + AlphanumericalLightCell( + signal = sig, + signalId = entry.id, + value = entry.value(liveTelemetryValues), + highlightColor = hlColor, + onSignalDrop = onSignalDrop + ) + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + itemsIndexed(visibleSignals, key = { _, entry -> entry.id }) { _, entry -> + val sig = entry.definition + val hlColor = highlightColors[entry.id] ?: highlightColors[sig.name] ?: 0L + + AlphanumericalTableRow( + signal = sig, + signalId = entry.id, + value = entry.value(liveTelemetryValues), + highlightColor = hlColor, + onSignalDrop = onSignalDrop + ) + } + } + } + + if (unresolvedIds.isNotEmpty()) { + Text( + text = "미연결 신호: ${unresolvedIds.joinToString()}", + modifier = Modifier.align(Alignment.BottomStart).background(Color(0xFFFFF3CD)).padding(4.dp), + color = Color(0xFF856404), fontSize = 10.sp, maxLines = 2 + ) + } + + if (isDragging) { + Box( + modifier = Modifier + .fillMaxSize() + .background(AlphaDragHighlightBg) + ) + } + } +} + +/** + * Alphanumerical 창 상단 타이틀바용 [Settings] 버튼 및 순수 영문 드롭다운 메뉴 + */ +@Composable +fun AlphanumericalSettingsButton( + displaySignals: List, + isTileMode: Boolean, + onToggleViewMode: () -> Unit, + onRemoveSignal: (String) -> Unit, + onClearAll: () -> Unit, + hiddenSignalIds: Set, + onToggleVisibility: (String) -> Unit, + highlightColors: Map, + onChangeHighlight: (String, Long) -> Unit, + onRename: () -> Unit = {} +) { + var expanded by remember { mutableStateOf(false) } + var currentSubMenu by remember { mutableStateOf(null) } + + Box { + Box( + modifier = Modifier + .size(20.dp) + .clickable { + expanded = true + currentSubMenu = null + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Alphanumerical Settings", + tint = Color(0xFF475569), + modifier = Modifier.size(15.dp) + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + currentSubMenu = null + } + ) { + when (currentSubMenu) { + // 1. Delete Item Submenu + "delete" -> { + DropdownMenuItem( + leadingIcon = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, modifier = Modifier.size(14.dp)) }, + text = { Text("◀ Back (Delete Item)", fontSize = 12.sp, fontWeight = FontWeight.Bold) }, + onClick = { currentSubMenu = null } + ) + HorizontalDivider(color = Color(0xFFE2E8F0)) + + if (displaySignals.isEmpty()) { + DropdownMenuItem( + text = { Text("No items registered", fontSize = 12.sp, color = Color.Gray) }, + onClick = {} + ) + } else { + for (entry in displaySignals) { + val sig = entry.definition + DropdownMenuItem( + leadingIcon = { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(sig.color.toComposeColor()) + ) + }, + text = { Text(sig.name, fontSize = 12.sp) }, + trailingIcon = { + Icon(Icons.Default.Delete, contentDescription = "Delete", tint = Color(0xFFEF4444), modifier = Modifier.size(14.dp)) + }, + onClick = { + onRemoveSignal(entry.id) + } + ) + } + + HorizontalDivider(color = Color(0xFFE2E8F0)) + + DropdownMenuItem( + leadingIcon = { + Icon(Icons.Default.DeleteForever, contentDescription = "Clear All", tint = Color(0xFFDC2626), modifier = Modifier.size(16.dp)) + }, + text = { Text("Clear All", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color(0xFFDC2626)) }, + onClick = { + onClearAll() + expanded = false + currentSubMenu = null + } + ) + } + } + + // 2. Show / Hide Submenu + "show_hide" -> { + DropdownMenuItem( + leadingIcon = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, modifier = Modifier.size(14.dp)) }, + text = { Text("◀ Back (Show / Hide)", fontSize = 12.sp, fontWeight = FontWeight.Bold) }, + onClick = { currentSubMenu = null } + ) + HorizontalDivider(color = Color(0xFFE2E8F0)) + + if (displaySignals.isEmpty()) { + DropdownMenuItem( + text = { Text("No items registered", fontSize = 12.sp, color = Color.Gray) }, + onClick = {} + ) + } else { + for (entry in displaySignals) { + val sig = entry.definition + val isHidden = (hiddenSignalIds.contains(entry.id) || hiddenSignalIds.contains(sig.name)) + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = if (!isHidden) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = null, + tint = if (!isHidden) Color(0xFF2563EB) else Color.Gray, + modifier = Modifier.size(16.dp) + ) + }, + text = { + Text( + sig.name, + fontSize = 12.sp, + color = if (!isHidden) Color.Black else Color.Gray + ) + }, + trailingIcon = { + Text(if (!isHidden) "✓ Visible" else "Hidden", fontSize = 10.sp, color = if (!isHidden) Color(0xFF2563EB) else Color.Gray) + }, + onClick = { + onToggleVisibility(entry.id) + } + ) + } + } + } + + // 3. Highlight Color Submenu + "highlight" -> { + DropdownMenuItem( + leadingIcon = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, modifier = Modifier.size(14.dp)) }, + text = { Text("◀ Back (Highlight Color)", fontSize = 12.sp, fontWeight = FontWeight.Bold) }, + onClick = { currentSubMenu = null } + ) + HorizontalDivider(color = Color(0xFFE2E8F0)) + + val colorOptions = listOf( + 0L to "Default", + 0xFFFEE2E2L to "Red", + 0xFFFFEDD5L to "Orange", + 0xFFDBEAFEL to "Blue" + ) + for (entry in displaySignals) { + val sig = entry.definition + Text( + text = "• ${sig.name}", + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + for ((cVal, cName) in colorOptions) { + val isSelected = (highlightColors[entry.id] ?: highlightColors[sig.name] ?: 0L) == cVal + Surface( + color = if (cVal == 0L) Color(0xFFF1F5F9) else Color(cVal), + shape = RoundedCornerShape(3.dp), + border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, Color(0xFF2563EB)) else null, + modifier = Modifier + .height(24.dp) + .clickable { onChangeHighlight(entry.id, cVal) } + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 6.dp)) { + Text( + text = cName, + fontSize = 9.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = Color.Black + ) + } + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + } + } + + // Main Root Menu (English only) + null -> { + Text( + text = "Alphanumerical Settings", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF475569), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + HorizontalDivider(color = Color(0xFFE2E8F0)) + + // 1. Delete Item + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.DeleteOutline, contentDescription = null, modifier = Modifier.size(16.dp), tint = Color(0xFFEF4444)) }, + text = { Text("Delete Item", fontSize = 12.sp, color = Color(0xFFDC2626)) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentSubMenu = "delete" } + ) + + // 2. Show / Hide + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(16.dp), tint = Color(0xFF2563EB)) }, + text = { Text("Show / Hide", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentSubMenu = "show_hide" } + ) + + // 3. Switch Mode + DropdownMenuItem( + leadingIcon = { Icon(if (isTileMode) Icons.AutoMirrored.Filled.List else Icons.Default.GridView, contentDescription = null, modifier = Modifier.size(16.dp)) }, + text = { Text(if (isTileMode) "Switch to Table Mode" else "Switch to Tile Grid Mode", fontSize = 12.sp) }, + onClick = { + onToggleViewMode() + expanded = false + } + ) + + DropdownMenuItem(text = { Text("Change Title Name", fontSize = 12.sp) }, onClick = { expanded = false; onRename() }) + + // 4. Highlight Color + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Palette, contentDescription = null, modifier = Modifier.size(16.dp)) }, + text = { Text("Highlight Color", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentSubMenu = "highlight" } + ) + } + } + } + } +} + +@Composable +private fun AlphanumericalLightCell( + signal: SignalDefinition, + signalId: String, + value: String, + highlightColor: Long = 0L, + onSignalDrop: ((DraggedSignal) -> Unit)? = null +) { + var cellPosInRoot by remember { mutableStateOf(Offset.Zero) } + val valueBgColor = if (highlightColor != 0L) Color(highlightColor) else AlphaValueBg + val signalColor = signal.color.toComposeColor() + val signalTextColor = if (signalColor.luminance() > 0.5f) Color.Black else Color.White + + Surface( + color = Color.White, + shape = RoundedCornerShape(2.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, AlphaCardBorder), + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .onGloballyPositioned { coords -> cellPosInRoot = coords.positionInRoot() } + .pointerInput(signalId, signal) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + SignalDragDropManager.startDrag( + signal = DraggedSignal( + signalId = signalId, + signalName = signal.name, + colorLong = signal.color, + unit = signal.unit, + isCan = signalId.startsWith("can_"), + isA2l = signalId.startsWith("a2l_"), + rawSignal = signal + ), + startPos = cellPosInRoot + localOffset + ) + }, + onDrag = { change, dragAmount -> + change.consume() + SignalDragDropManager.updateDrag(dragAmount) + }, + onDragEnd = { + SignalDragDropManager.endDrag { droppedSignal -> + onSignalDrop?.invoke(droppedSignal) + } + }, + onDragCancel = { + SignalDragDropManager.cancelDrag() + } + ) + } + ) { + Column(modifier = Modifier.fillMaxSize()) { + // 1. Title Header Bar (Blue background with Auto-scrolling Marquee Name) + Box( + modifier = Modifier + .fillMaxWidth() + .height(22.dp) + .background(signalColor) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = signal.name, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = signalTextColor, + maxLines = 1, + modifier = Modifier.basicMarquee( + iterations = Int.MAX_VALUE, + delayMillis = 1000 + ), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + + // 2. Value Content Area (Gray background with Centered Value Text) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(valueBgColor) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = value, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = AlphaValueText, + fontFamily = FontFamily.SansSerif, + maxLines = 1, + textAlign = TextAlign.Center, + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } + } +} + +@Composable +private fun AlphanumericalTableRow( + signal: SignalDefinition, + signalId: String, + value: String, + highlightColor: Long = 0L, + onSignalDrop: ((DraggedSignal) -> Unit)? = null +) { + var cellPosInRoot by remember { mutableStateOf(Offset.Zero) } + val valueBgColor = if (highlightColor != 0L) Color(highlightColor) else AlphaValueBg + val signalColor = signal.color.toComposeColor() + val signalTextColor = if (signalColor.luminance() > 0.5f) Color.Black else Color.White + + Surface( + color = Color.White, + shape = RoundedCornerShape(2.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, AlphaCardBorder), + modifier = Modifier + .width(280.dp) + .height(28.dp) + .onGloballyPositioned { coords -> cellPosInRoot = coords.positionInRoot() } + .pointerInput(signalId, signal) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + SignalDragDropManager.startDrag( + signal = DraggedSignal( + signalId = signalId, + signalName = signal.name, + colorLong = signal.color, + unit = signal.unit, + isCan = signalId.startsWith("can_"), + isA2l = signalId.startsWith("a2l_"), + rawSignal = signal + ), + startPos = cellPosInRoot + localOffset + ) + }, + onDrag = { change, dragAmount -> + change.consume() + SignalDragDropManager.updateDrag(dragAmount) + }, + onDragEnd = { + SignalDragDropManager.endDrag { droppedSignal -> + onSignalDrop?.invoke(droppedSignal) + } + }, + onDragCancel = { + SignalDragDropManager.cancelDrag() + } + ) + } + ) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + // Name Area (Blue) + Box( + modifier = Modifier + .weight(0.55f) + .fillMaxHeight() + .background(signalColor) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = signal.name, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = signalTextColor, + maxLines = 1, + modifier = Modifier.basicMarquee( + iterations = Int.MAX_VALUE, + delayMillis = 1000 + ), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color.White)) + + // Value Area (Gray) + Box( + modifier = Modifier + .weight(0.45f) + .fillMaxHeight() + .background(valueBgColor) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = value, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = AlphaValueText, + maxLines = 1, + textAlign = TextAlign.Center, + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaEditDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaEditDialog.kt new file mode 100644 index 0000000..e0f9f73 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaEditDialog.kt @@ -0,0 +1,274 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Functions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.dct.app.core.formula.FormulaEvaluator +import com.dct.app.domain.model.formula.FormulaDefinition +import java.util.Locale + +// 기본 신호 색상 프리셋 +private val FORMULA_COLOR_PRESETS = listOf( + 0xFF2196F3L, // Blue + 0xFF4CAF50L, // Green + 0xFFFF9800L, // Orange + 0xFFE91E63L, // Pink + 0xFF9C27B0L, // Purple + 0xFF00BCD4L, // Cyan + 0xFFFFEB3BL, // Yellow + 0xFFF44336L // Red +) + +/** + * Formula 수식 생성 및 편집 대화상자 (InputFormulaDlg) + */ +@Composable +fun FormulaEditDialog( + initialFormula: FormulaDefinition? = null, + liveTelemetryValues: Map = emptyMap(), + onDismiss: () -> Unit, + onSave: (FormulaDefinition) -> Unit +) { + var name by remember { mutableStateOf(initialFormula?.name ?: "") } + var expression by remember { mutableStateOf(initialFormula?.expression ?: "") } + var unit by remember { mutableStateOf(initialFormula?.unit ?: "") } + var color by remember { mutableStateOf(initialFormula?.color ?: 0xFF2196F3L) } + var isForSm by remember { mutableStateOf(initialFormula?.isForSm ?: false) } + var description by remember { mutableStateOf(initialFormula?.description ?: "") } + + var errorMessage by remember { mutableStateOf(null) } + + // 실시간 수식 미리보기 계산 + val previewResult by remember(expression, liveTelemetryValues) { + derivedStateOf { + if (expression.isBlank()) { + "수식을 입력하세요" + } else { + val isValid = FormulaEvaluator.validate(expression) + if (!isValid) { + "문법 오류" + } else { + val calc = FormulaEvaluator.evaluate(expression, liveTelemetryValues) + if (calc.isNaN()) { + "계산 불가 (변수 미수신)" + } else { + String.format(Locale.US, "%.3f", calc) + } + } + } + } + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.White, + shadowElevation = 8.dp, + modifier = Modifier.width(440.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Functions, + contentDescription = null, + tint = Color(0xFF1E88E5), + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (initialFormula == null) "새 수식 추가 (Add Formula)" else "수식 수정 (Edit Formula)", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1E293B) + ) + } + IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, contentDescription = "닫기", tint = Color(0xFF64748B)) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp), color = Color(0xFFE2E8F0)) + + // 1. Name 입력 + Text("수식 명칭 (Name)", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF475569)) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = name, + onValueChange = { + name = it + errorMessage = null + }, + placeholder = { Text("예: Power_kW, Diff_Temp", fontSize = 13.sp, color = Color(0xFF94A3B8)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 2. Expression 입력 + Text("수식 표현식 (Expression)", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF475569)) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = expression, + onValueChange = { + expression = it + errorMessage = null + }, + placeholder = { Text("예: \"Torque\" * \"EngineSpeed\" / 9549", fontSize = 13.sp, color = Color(0xFF94A3B8)) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 수식 미리보기 + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF8FAFC), RoundedCornerShape(4.dp)) + .border(1.dp, Color(0xFFE2E8F0), RoundedCornerShape(4.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("실시간 미리보기:", fontSize = 11.sp, color = Color(0xFF64748B)) + Text( + text = previewResult, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = if (previewResult == "문법 오류") Color.Red else Color(0xFF0F172A) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 3. Unit 및 색상 + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("단위 (Unit)", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF475569)) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = unit, + onValueChange = { unit = it }, + placeholder = { Text("예: kW, 'C, %", fontSize = 13.sp, color = Color(0xFF94A3B8)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text("차트 표시 색상", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF475569)) + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FORMULA_COLOR_PRESETS.take(6).forEach { c -> + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(Color(c)) + .border( + width = if (color == c) 2.dp else 1.dp, + color = if (color == c) Color.Black else Color(0x33000000), + shape = CircleShape + ) + .clickable { color = c } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 4. For SM 체크박스 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { isForSm = !isForSm } + ) { + Checkbox( + checked = isForSm, + onCheckedChange = { isForSm = it } + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Sensor Module 연동 수식 (For SM)", fontSize = 12.sp, color = Color(0xFF334155)) + } + + if (errorMessage != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text(errorMessage!!, fontSize = 12.sp, color = Color.Red) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Footer Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + OutlinedButton(onClick = onDismiss) { + Text("취소") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (name.isBlank()) { + errorMessage = "수식 명칭(Name)을 입력해 주십시오." + return@Button + } + if (expression.isBlank()) { + errorMessage = "수식 표현식(Expression)을 입력해 주십시오." + return@Button + } + if (!FormulaEvaluator.validate(expression)) { + errorMessage = "올바르지 않은 수식 문법입니다." + return@Button + } + val updated = (initialFormula ?: FormulaDefinition(name = name, expression = expression)).copy( + name = name.trim(), + expression = expression.trim(), + unit = unit.trim(), + color = color, + isForSm = isForSm, + description = description.trim() + ) + onSave(updated) + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E88E5)) + ) { + Text("저장") + } + } + } + } + } +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaWindowView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaWindowView.kt new file mode 100644 index 0000000..ad6d414 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaWindowView.kt @@ -0,0 +1,304 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Functions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.core.formula.FormulaEvaluator +import com.dct.app.domain.model.formula.FormulaDefinition +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.SignalDragDropManager +import com.dct.app.presentation.signaltree.TableSubHeader +import java.util.Locale +import com.dct.app.presentation.signaltree.rememberWindowString +import com.dct.app.presentation.signaltree.rememberWindowInt + +/** + * Formula (가상 수식 계산기) 윈도우 콘텐츠 컴포넌트 (Windows Classic 스타일) + */ +@Composable +fun FormulaWindowContent( + formulas: List, + liveTelemetryValues: Map = emptyMap(), + onAddFormula: (FormulaDefinition) -> Unit, + onUpdateFormula: (FormulaDefinition) -> Unit, + onDeleteFormula: (FormulaDefinition) -> Unit, + onSignalDrop: (DraggedSignal) -> Unit = {}, + onFormulaClick: (FormulaDefinition) -> Unit = {} +) { + var searchQuery by rememberWindowString("search") + var editingFormula by remember { mutableStateOf(null) } + var isAddingNew by remember { mutableStateOf(false) } + + val filteredFormulas = remember(formulas, searchQuery) { + if (searchQuery.isBlank()) formulas + else formulas.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.expression.contains(searchQuery, ignoreCase = true) || + it.unit.contains(searchQuery, ignoreCase = true) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + // 상단 검색 바 및 수식 추가 버튼 + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF1F3F5)) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box(modifier = Modifier.weight(1f)) { + TableSubHeader( + searchQuery = searchQuery, + onSearchChange = { searchQuery = it } + ) + } + Spacer(modifier = Modifier.width(6.dp)) + Button( + onClick = { isAddingNew = true }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E88E5)), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + shape = RoundedCornerShape(4.dp), + modifier = Modifier.height(28.dp) + ) { + Icon(Icons.Default.Add, contentDescription = "Add", modifier = Modifier.size(14.dp), tint = Color.White) + Spacer(modifier = Modifier.width(3.dp)) + Text("Add", fontSize = 11.sp, color = Color.White, fontWeight = FontWeight.Bold) + } + } + + // Table Header & Rows + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + Column(modifier = Modifier.width(420.dp).fillMaxHeight()) { + // Table Header + Row( + modifier = Modifier + .fillMaxWidth() + .height(26.dp) + .background(Color(0xFFE9ECEF)) + .border(1.dp, Color(0xFFCED4DA)), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Name", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(110.dp).padding(horizontal = 6.dp)) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) + Text("Value", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(80.dp).padding(horizontal = 6.dp)) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) + Text("expression", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.weight(1f).padding(horizontal = 6.dp)) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) + Text("Act", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(50.dp).padding(horizontal = 4.dp)) + } + + // Table Body + if (filteredFormulas.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Default.Functions, contentDescription = null, tint = Color(0xFFADB5BD), modifier = Modifier.size(32.dp)) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (formulas.isEmpty()) "등록된 수식이 없습니다.\n상단 [+ Add] 버튼을 눌러 새 수식을 추가하세요." else "검색 결과가 없습니다.", + fontSize = 12.sp, + color = Color(0xFF6C757D), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + itemsIndexed(filteredFormulas, key = { _, f -> f.id }) { index, formula -> + var itemBounds by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } + + // 실시간 계산 결과 (텔레메트리 맵에서 조회 또는 즉시 평가) + val liveVal = liveTelemetryValues[formula.name] + ?: FormulaEvaluator.evaluate(formula.expression, liveTelemetryValues) + + val valStr = if (liveVal.isNaN()) { + "0.000" + } else { + String.format(Locale.US, "%.3f", liveVal) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(28.dp) + .background(if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) + .border(1.dp, Color(0xFFF1F3F5)) + .onGloballyPositioned { coords -> + itemBounds = coords.boundsInRoot() + } + .pointerInput(formula) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + SignalDragDropManager.startDrag( + DraggedSignal( + signalId = "formula_${formula.name}", + signalName = formula.name, + colorLong = formula.color, + unit = formula.unit, + isCan = false, + isA2l = false + ), + itemBounds.topLeft + localOffset + ) + }, + onDrag = { change, dragAmount -> + change.consume() + SignalDragDropManager.updateDrag(dragAmount) + }, + onDragEnd = { + SignalDragDropManager.endDrag { droppedSignal -> + onSignalDrop(droppedSignal) + } + }, + onDragCancel = { + SignalDragDropManager.cancelDrag() + } + ) + } + .clickable { onFormulaClick(formula) }, + verticalAlignment = Alignment.CenterVertically + ) { + // 1. Name & Color indicator + Row( + modifier = Modifier.width(110.dp).padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(Color(formula.color), RoundedCornerShape(2.dp)) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = formula.name, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF212529), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) + + // 2. Value + Text( + text = valStr, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = if (liveVal.isNaN()) Color.Red else Color(0xFF0F172A), + modifier = Modifier.width(80.dp).padding(horizontal = 6.dp), + maxLines = 1 + ) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) + + // 3. Expression + Text( + text = formula.expression, + fontSize = 11.sp, + color = Color(0xFF495057), + modifier = Modifier.weight(1f).padding(horizontal = 6.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) + + // 4. Action Buttons (Edit / Delete) + Row( + modifier = Modifier.width(50.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { editingFormula = formula }, + modifier = Modifier.size(22.dp) + ) { + Icon(Icons.Default.Edit, contentDescription = "Edit", modifier = Modifier.size(13.dp), tint = Color(0xFF1E88E5)) + } + IconButton( + onClick = { onDeleteFormula(formula) }, + modifier = Modifier.size(22.dp) + ) { + Icon(Icons.Default.Delete, contentDescription = "Delete", modifier = Modifier.size(13.dp), tint = Color(0xFFE53935)) + } + } + } + } + } + } + } + } + } + + // 신규 수식 추가 다이얼로그 + if (isAddingNew) { + FormulaEditDialog( + initialFormula = null, + liveTelemetryValues = liveTelemetryValues, + onDismiss = { isAddingNew = false }, + onSave = { newFormula -> + onAddFormula(newFormula) + isAddingNew = false + } + ) + } + + // 기존 수식 수정 다이얼로그 + if (editingFormula != null) { + FormulaEditDialog( + initialFormula = editingFormula, + liveTelemetryValues = liveTelemetryValues, + onDismiss = { editingFormula = null }, + onSave = { updated -> + onUpdateFormula(updated) + editingFormula = null + } + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/GraphWindowView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/GraphWindowView.kt new file mode 100644 index 0000000..0924db8 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/GraphWindowView.kt @@ -0,0 +1,584 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.LineWeight +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.core.theme.toComposeColor +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.ChartRenderSnapshot +import com.dct.app.domain.model.chart.InterpolationMode +import com.dct.app.domain.model.chart.SeriesRenderData +import com.dct.app.domain.model.chart.TimeWindowPreset +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.GraphYAxisDialog +import com.dct.app.presentation.chart.MultiXGraphCanvas +import com.dct.app.presentation.chart.SignalDragDropManager +import java.util.Locale + +/** + * 실시간 그래프 윈도우 컴포넌트 (PC GraphWnd 메뉴 구성 기반) + */ +@Composable +fun GraphWindowContent( + snapshot: ChartRenderSnapshot, + windowId: String = "chart_window", + isMultiAxis: Boolean = false, + onRemoveSignal: (String) -> Unit, + onClearAll: () -> Unit, + onCrosshairTimeChange: ((Double?) -> Unit)? = null, + onPanTime: ((Double) -> Unit)? = null, + onZoomTime: ((Float, Double) -> Unit)? = null, + onResetPan: (() -> Unit)? = null, + onSignalDrop: ((DraggedSignal) -> Unit)? = null, + onToggleVisibility: ((String) -> Unit)? = null, + onChangeThickness: ((String, Float) -> Unit)? = null, + onChangeDashStyle: ((String, Int) -> Unit)? = null, + onSelectLegendPosition: ((Int) -> Unit)? = null, + onSelectTimePreset: ((TimeWindowPreset) -> Unit)? = null, + onSelectXAxisManual: (() -> Unit)? = null, + onSelectYAxisAuto: (() -> Unit)? = null, + onOpenYAxisSetting: ((SeriesRenderData?) -> Unit)? = null, + onChangeTitleName: (() -> Unit)? = null, + onQuitWindow: (() -> Unit)? = null +) { + val density = LocalDensity.current + val isDragging = SignalDragDropManager.isDragging + var isContextMenuOpen by remember { mutableStateOf(false) } + var contextMenuOffset by remember { mutableStateOf(DpOffset.Zero) } + + DisposableEffect(windowId) { + onDispose { + SignalDragDropManager.unregisterDropTarget(windowId) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .onGloballyPositioned { coordinates -> + SignalDragDropManager.registerDropTarget(windowId, coordinates.boundsInRoot()) + } + .then( + if (isDragging) { + Modifier.border(2.dp, Color(0xFF2563EB), RoundedCornerShape(2.dp)) + } else Modifier + ) + ) { + MultiXGraphCanvas( + snapshot = snapshot, + modifier = Modifier.fillMaxSize(), + isMultiAxis = isMultiAxis, + onRemoveSignal = onRemoveSignal, + onCrosshairTimeChange = onCrosshairTimeChange, + onPanTime = onPanTime, + onZoomTime = onZoomTime, + onResetPan = onResetPan, + onOpenContextMenu = { offset -> + contextMenuOffset = with(density) { DpOffset(offset.x.toDp(), offset.y.toDp()) } + isContextMenuOpen = true + } + ) + + // PC 레거시 컨텍스트 메뉴 + Box(Modifier.offset(contextMenuOffset.x, contextMenuOffset.y).size(1.dp)) { + GraphContextMenu( + expanded = isContextMenuOpen, + onDismissRequest = { isContextMenuOpen = false }, + snapshot = snapshot, + isMultiAxis = isMultiAxis, + offset = DpOffset.Zero, + onRemoveSignal = { onRemoveSignal(it); isContextMenuOpen = false }, + onToggleVisibility = { onToggleVisibility?.invoke(it) }, + onChangeThickness = { id, th -> onChangeThickness?.invoke(id, th); isContextMenuOpen = false }, + onChangeDashStyle = { id, st -> onChangeDashStyle?.invoke(id, st); isContextMenuOpen = false }, + onSelectLegendPosition = { pos -> onSelectLegendPosition?.invoke(pos); isContextMenuOpen = false }, + onSelectTimePreset = { preset -> onSelectTimePreset?.invoke(preset); isContextMenuOpen = false }, + onSelectXAxisManual = { onSelectXAxisManual?.invoke(); isContextMenuOpen = false }, + onSelectYAxisAuto = { onSelectYAxisAuto?.invoke(); isContextMenuOpen = false }, + onOpenYAxisSetting = { series -> onOpenYAxisSetting?.invoke(series); isContextMenuOpen = false }, + onChangeTitleName = { onChangeTitleName?.invoke(); isContextMenuOpen = false }, + onQuitWindow = { onQuitWindow?.invoke(); isContextMenuOpen = false } + ) + } + + if (isDragging) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x153B82F6)) + ) + } + } +} + +/** + * PC GraphWnd 구성에 대응하는 계층형 컨텍스트 메뉴 + */ +@Composable +fun GraphContextMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + snapshot: ChartRenderSnapshot, + isMultiAxis: Boolean, + offset: DpOffset = DpOffset.Zero, + onRemoveSignal: (String) -> Unit, + onToggleVisibility: (String) -> Unit, + onChangeThickness: (String, Float) -> Unit, + onChangeDashStyle: (String, Int) -> Unit, + onSelectLegendPosition: (Int) -> Unit, + onSelectTimePreset: (TimeWindowPreset) -> Unit, + onSelectXAxisManual: () -> Unit, + onSelectYAxisAuto: () -> Unit, + onOpenYAxisSetting: (SeriesRenderData?) -> Unit, + onChangeTitleName: () -> Unit, + onQuitWindow: () -> Unit +) { + var currentMenuPath by remember(expanded) { mutableStateOf>(emptyList()) } + var selectedSignalForSubmenu by remember(expanded) { mutableStateOf(null) } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { + currentMenuPath = emptyList() + selectedSignalForSubmenu = null + onDismissRequest() + }, + offset = offset, + modifier = Modifier + .background(Color.White) + .border(1.dp, Color(0xFFCBD5E1), RoundedCornerShape(4.dp)) + .width(220.dp) + ) { + val path = currentMenuPath.joinToString("/") + + when (path) { + // 1. Remove Submenu + "remove" -> { + SubMenuHeader(title = "Remove", onBack = { currentMenuPath = emptyList() }) + if (snapshot.seriesList.isEmpty()) { + EmptyItem("No signals registered") + } else { + for (series in snapshot.seriesList) { + DropdownMenuItem( + leadingIcon = { SignalColorDot(series.color) }, + text = { Text(series.signalName, fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.Delete, contentDescription = null, tint = Color(0xFFEF4444), modifier = Modifier.size(14.dp)) }, + onClick = { onRemoveSignal(series.signalId) } + ) + } + } + } + + // 2. Show/Hide Submenu + "show_hide" -> { + SubMenuHeader(title = "Show/Hide", onBack = { currentMenuPath = emptyList() }) + if (snapshot.seriesList.isEmpty()) { + EmptyItem("No signals registered") + } else { + for (series in snapshot.seriesList) { + DropdownMenuItem( + leadingIcon = { + if (series.isVisible) { + Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + } else { + Spacer(modifier = Modifier.size(16.dp)) + } + }, + text = { Text(series.signalName, fontSize = 12.sp, color = if (series.isVisible) Color(0xFF0F172A) else Color.Gray) }, + onClick = { onToggleVisibility(series.signalId) } + ) + } + } + } + + // 3. Thickness Submenu (Signal list -> Thickness 1~5px) + "thickness" -> { + SubMenuHeader(title = "Thickness", onBack = { currentMenuPath = emptyList() }) + if (snapshot.seriesList.isEmpty()) { + EmptyItem("No signals registered") + } else { + for (series in snapshot.seriesList) { + DropdownMenuItem( + leadingIcon = { SignalColorDot(series.color) }, + text = { Text("${series.signalName} (${series.lineWidthDp.toInt()}px)", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { + selectedSignalForSubmenu = series + currentMenuPath = listOf("thickness", series.signalId) + } + ) + } + } + } + "thickness/" + (selectedSignalForSubmenu?.signalId ?: "") -> { + val targetSig = snapshot.seriesList.find { it.signalId == selectedSignalForSubmenu?.signalId } + SubMenuHeader(title = targetSig?.signalName ?: "Thickness", onBack = { currentMenuPath = listOf("thickness") }) + if (targetSig != null) { + val thicknesses = listOf(1.0f to "1px", 2.0f to "2px", 3.0f to "3px", 4.0f to "4px", 5.0f to "5px") + for ((th, label) in thicknesses) { + val isSelected = kotlin.math.abs(targetSig.lineWidthDp - th) < 0.1f + DropdownMenuItem( + leadingIcon = { + if (isSelected) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + else Spacer(modifier = Modifier.size(16.dp)) + }, + text = { Text(label, fontSize = 12.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, + onClick = { onChangeThickness(targetSig.signalId, th) } + ) + } + } + } + + // 4. Dash Style Submenu (Solid, Dash, Dot, Dash dot, Dash dot dot) + "dash_style" -> { + SubMenuHeader(title = "Dash style", onBack = { currentMenuPath = emptyList() }) + if (snapshot.seriesList.isEmpty()) { + EmptyItem("No signals registered") + } else { + for (series in snapshot.seriesList) { + DropdownMenuItem( + leadingIcon = { SignalColorDot(series.color) }, + text = { Text(series.signalName, fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { + selectedSignalForSubmenu = series + currentMenuPath = listOf("dash_style", series.signalId) + } + ) + } + } + } + "dash_style/" + (selectedSignalForSubmenu?.signalId ?: "") -> { + val targetSig = snapshot.seriesList.find { it.signalId == selectedSignalForSubmenu?.signalId } + SubMenuHeader(title = targetSig?.signalName ?: "Dash style", onBack = { currentMenuPath = listOf("dash_style") }) + if (targetSig != null) { + val styles = listOf(0 to "Solid", 1 to "Dash", 2 to "Dot", 3 to "Dash dot", 4 to "Dash dot dot") + for ((st, label) in styles) { + val isSelected = (targetSig.dashStyle == st) + DropdownMenuItem( + leadingIcon = { + if (isSelected) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + else Spacer(modifier = Modifier.size(16.dp)) + }, + text = { Text(label, fontSize = 12.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, + onClick = { onChangeDashStyle(targetSig.signalId, st) } + ) + } + } + } + + // 5. Legend Submenu (Right, Top, Bottom, None) + "legend" -> { + SubMenuHeader(title = "Legend", onBack = { currentMenuPath = emptyList() }) + val positions = listOf(0 to "Right", 1 to "Top", 2 to "Bottom", 3 to "None") + for ((pos, label) in positions) { + val isSelected = (snapshot.legendPosition == pos) + DropdownMenuItem( + leadingIcon = { + if (isSelected) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + else Spacer(modifier = Modifier.size(16.dp)) + }, + text = { Text(label, fontSize = 12.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, + onClick = { onSelectLegendPosition(pos) } + ) + } + } + + // 6. X-Axis Submenu (Auto -> [Auto, 10s, 30s, 1min, 5min, 10min] / Manual) + "xaxis" -> { + SubMenuHeader(title = "X-Axis", onBack = { currentMenuPath = emptyList() }) + DropdownMenuItem( + leadingIcon = { + if (snapshot.isXAxisAuto) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + else Spacer(modifier = Modifier.size(16.dp)) + }, + text = { Text("Auto", fontSize = 12.sp, fontWeight = if (snapshot.isXAxisAuto) FontWeight.Bold else FontWeight.Normal) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { + currentMenuPath = listOf("xaxis", "auto") + } + ) + DropdownMenuItem( + leadingIcon = { + if (!snapshot.isXAxisAuto) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + else Spacer(modifier = Modifier.size(16.dp)) + }, + text = { Text("Manual", fontSize = 12.sp, fontWeight = if (!snapshot.isXAxisAuto) FontWeight.Bold else FontWeight.Normal) }, + onClick = { onSelectXAxisManual() } + ) + } + "xaxis/auto" -> { + SubMenuHeader(title = "Auto", onBack = { currentMenuPath = listOf("xaxis") }) + val presets = listOf( + TimeWindowPreset.AUTO to "Auto", + TimeWindowPreset.SEC_10 to "10s", + TimeWindowPreset.SEC_30 to "30s", + TimeWindowPreset.MIN_1 to "1min", + TimeWindowPreset.MIN_5 to "5min", + TimeWindowPreset.MIN_10 to "10min" + ) + for ((preset, label) in presets) { + val isSelected = snapshot.isXAxisAuto && snapshot.xAxisPreset == preset + DropdownMenuItem( + leadingIcon = { + if (isSelected) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + else Spacer(modifier = Modifier.size(16.dp)) + }, + text = { Text(label, fontSize = 12.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, + onClick = { onSelectTimePreset(preset) } + ) + } + } + + // 7. Y-Axis Submenu (Auto / Manual) + "yaxis" -> { + SubMenuHeader(title = "Y-Axis", onBack = { currentMenuPath = emptyList() }) + DropdownMenuItem( + leadingIcon = { + if (snapshot.isYAxisAuto) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + else Spacer(modifier = Modifier.size(16.dp)) + }, + text = { Text("Auto", fontSize = 12.sp, fontWeight = if (snapshot.isYAxisAuto) FontWeight.Bold else FontWeight.Normal) }, + onClick = { onSelectYAxisAuto() } + ) + val hasMultipleSignals = isMultiAxis && snapshot.seriesList.size > 1 + DropdownMenuItem( + leadingIcon = { + if (!snapshot.isYAxisAuto) Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFF2563EB), modifier = Modifier.size(16.dp)) + else Spacer(modifier = Modifier.size(16.dp)) + }, + text = { Text("Manual", fontSize = 12.sp, fontWeight = if (!snapshot.isYAxisAuto) FontWeight.Bold else FontWeight.Normal) }, + trailingIcon = { + if (hasMultipleSignals) Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) + else Icon(Icons.Default.Tune, contentDescription = null, modifier = Modifier.size(14.dp)) + }, + onClick = { + if (hasMultipleSignals) { + currentMenuPath = listOf("yaxis", "manual") + } else { + onOpenYAxisSetting(snapshot.seriesList.firstOrNull()) + } + } + ) + } + "yaxis/manual" -> { + SubMenuHeader(title = "Select Signal Axis", onBack = { currentMenuPath = listOf("yaxis") }) + if (snapshot.seriesList.isEmpty()) { + EmptyItem("No signals registered") + } else { + for (series in snapshot.seriesList) { + val axisLabel = (if (series.axisPosition.isLeft) "L" else "R") + (series.axisPosition.index + 1) + DropdownMenuItem( + leadingIcon = { SignalColorDot(series.color) }, + text = { Text("${series.signalName} ($axisLabel)", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.Tune, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { onOpenYAxisSetting(series) } + ) + } + } + } + + // Root Menu + else -> { + // 1. Remove + DropdownMenuItem( + text = { Text("Remove", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentMenuPath = listOf("remove") } + ) + HorizontalDivider(color = Color(0xFFE2E8F0)) + + // 2. Show/Hide + DropdownMenuItem( + text = { Text("Show/Hide", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentMenuPath = listOf("show_hide") } + ) + HorizontalDivider(color = Color(0xFFE2E8F0)) + + // 3. Thickness + DropdownMenuItem( + text = { Text("Thickness", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentMenuPath = listOf("thickness") } + ) + + // 4. Dash style + DropdownMenuItem( + text = { Text("Dash style", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentMenuPath = listOf("dash_style") } + ) + + // 5. Legend + DropdownMenuItem( + text = { Text("Legend", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentMenuPath = listOf("legend") } + ) + HorizontalDivider(color = Color(0xFFE2E8F0)) + + // 6. X-Axis + DropdownMenuItem( + text = { Text("X-Axis", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentMenuPath = listOf("xaxis") } + ) + + // 7. Y-Axis + DropdownMenuItem( + text = { Text("Y-Axis", fontSize = 12.sp) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(14.dp)) }, + onClick = { currentMenuPath = listOf("yaxis") } + ) + HorizontalDivider(color = Color(0xFFE2E8F0)) + + // 8. Change Title Name + DropdownMenuItem( + text = { Text("Change Title Name", fontSize = 12.sp) }, + onClick = { onChangeTitleName() } + ) + + // 9. Quit + DropdownMenuItem( + text = { Text("Quit", fontSize = 12.sp, color = Color(0xFFDC2626)) }, + onClick = { onQuitWindow() } + ) + } + } + } +} + +@Composable +private fun SubMenuHeader(title: String, onBack: () -> Unit) { + DropdownMenuItem( + leadingIcon = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(14.dp)) }, + text = { Text("Back ($title)", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color(0xFF2563EB)) }, + onClick = onBack + ) + HorizontalDivider(color = Color(0xFFE2E8F0)) +} + +@Composable +private fun EmptyItem(message: String) { + DropdownMenuItem( + text = { Text(message, fontSize = 11.sp, color = Color.Gray) }, + onClick = {} + ) +} + +@Composable +private fun SignalColorDot(colorLong: Long) { + Box( + modifier = Modifier + .size(10.dp) + .background(colorLong.toComposeColor(), CircleShape) + .border(0.5.dp, Color.Gray, CircleShape) + ) +} + + +/** + * 타이틀바 설정(톱니바퀴) 아이콘 버튼 컴포넌트 + */ +@Composable +fun GraphSettingsButton( + snapshot: ChartRenderSnapshot, + isMultiAxis: Boolean, + onRemoveSignal: (String) -> Unit, + onClearAll: () -> Unit, + onToggleVisibility: (String) -> Unit, + onChangeThickness: (String, Float) -> Unit, + onChangeDashStyle: (String, Int) -> Unit = { _, _ -> }, + onSelectLegendPosition: (Int) -> Unit = {}, + onSelectTimePreset: (TimeWindowPreset) -> Unit, + onSelectXAxisManual: () -> Unit = {}, + onSelectYAxisAuto: () -> Unit = {}, + onOpenYAxisSetting: (SeriesRenderData?) -> Unit, + onChangeTitleName: () -> Unit = {}, + onQuitWindow: () -> Unit = {} +) { + var isExpanded by remember { mutableStateOf(false) } + + Box { + IconButton( + onClick = { isExpanded = true }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Graph Settings", + tint = Color(0xFF475569), + modifier = Modifier.size(16.dp) + ) + } + + GraphContextMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false }, + snapshot = snapshot, + isMultiAxis = isMultiAxis, + onRemoveSignal = { onRemoveSignal(it); isExpanded = false }, + onToggleVisibility = { onToggleVisibility(it) }, + onChangeThickness = { id, th -> onChangeThickness(id, th); isExpanded = false }, + onChangeDashStyle = { id, st -> onChangeDashStyle(id, st); isExpanded = false }, + onSelectLegendPosition = { pos -> onSelectLegendPosition(pos); isExpanded = false }, + onSelectTimePreset = { preset -> onSelectTimePreset(preset); isExpanded = false }, + onSelectXAxisManual = { onSelectXAxisManual(); isExpanded = false }, + onSelectYAxisAuto = { onSelectYAxisAuto(); isExpanded = false }, + onOpenYAxisSetting = { series -> onOpenYAxisSetting(series); isExpanded = false }, + onChangeTitleName = { onChangeTitleName(); isExpanded = false }, + onQuitWindow = { onQuitWindow(); isExpanded = false } + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MdiSubWindowFrame.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MdiSubWindowFrame.kt new file mode 100644 index 0000000..786fddd --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MdiSubWindowFrame.kt @@ -0,0 +1,786 @@ +package com.dct.app.presentation.monitor.components + + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.SouthEast +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import com.dct.app.domain.model.chart.ChartRenderSnapshot +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.chart.SeriesRenderData +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.presentation.chart.GraphYAxisDialog +import com.dct.app.presentation.chart.SignalDragDropManager +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.mdi.MdiWindow +import com.dct.app.presentation.mdi.MdiWindowType + + + +/** + * MDI 서브윈도우 프레임 컴포넌트 + * (헤더 드래그 이동, 리사이즈 핸들, ◀ / ▶ 탭 간 창 이동 화살표, X 닫기, 서브윈도우 타입별 콘텐츠 임베딩) + */ +@Composable +fun MdiSubWindow( + window: MdiWindow, + viewModel: MainViewModel, + canSignals: List, + a2lSignals: List = emptyList(), + a2lCharacteristics: List = emptyList(), + a2lCharacteristicsArray: List = emptyList(), + mdbSignals: List, + liveTelemetryValues: Map, + chartSnapshot: ChartRenderSnapshot, + density: Float, + viewportScrollX: Float = 0f, + viewportScrollY: Float = 0f, + onClose: () -> Unit, + onBringToFront: () -> Unit, + onMove: (dx: Float, dy: Float) -> Unit, + onResize: (dw: Float, dh: Float) -> Unit, + onDragStart: (androidx.compose.ui.geometry.Offset) -> Unit = {}, + onDragEnd: () -> Unit = {}, + onDragCancel: () -> Unit = onDragEnd, + onDragPointerPosition: (Offset) -> Unit = {} +) { + val currentOnMove by rememberUpdatedState(onMove) + val currentOnResize by rememberUpdatedState(onResize) + val currentOnClose by rememberUpdatedState(onClose) + val currentOnDragStart by rememberUpdatedState(onDragStart) + val currentOnDragEnd by rememberUpdatedState(onDragEnd) + val currentOnDragCancel by rememberUpdatedState(onDragCancel) + val currentOnDragPointerPosition by rememberUpdatedState(onDragPointerPosition) + var windowPositionInRoot by remember { mutableStateOf(Offset.Zero) } + var headerPositionInRoot by remember { mutableStateOf(Offset.Zero) } + + var editingSeriesForYAxis by remember { mutableStateOf(null) } + val contentRevision by viewModel.workspaceRevision.collectAsState() + val context = androidx.compose.ui.platform.LocalContext.current + val isTileMode = window.config.isTileMode + val hiddenAlphaSignals = window.config.hiddenSignalIds + val highlightColors = window.config.highlightColors + + val isGraphWindow = window.type in setOf(MdiWindowType.GRAPH, MdiWindowType.CHART, MdiWindowType.MULTI_AXIS_GRAPH) + + if (isGraphWindow) { + DisposableEffect(window.id) { + onDispose { + SignalDragDropManager.unregisterDropTarget(window.id) + } + } + } + + // 서브 윈도우 내부(테이블/목록 등)에서 스크롤 시, 스크롤 끝에 도달했을 때 잔여 스크롤이 상위 메인 캔버스를 움직이지 않도록 전파 차단 + val stopNestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + // 남은 스크롤 델타를 모두 소비하여 부모 메인 캔버스로의 스크롤 전파를 방지 + return available + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + // 남은 관성 스크롤(Fling) 속도를 모두 소비하여 부모 메인 캔버스로의 전파를 방지 + return available + } + } + } + + com.dct.app.presentation.signaltree.WindowUiStateProvider(window.id, window.config.contentState, + { viewModel.mdiViewModel.updateWindowContentState(window.id, it) }) { + Surface( + color = Color.White, + shape = RoundedCornerShape(2.dp), + shadowElevation = 4.dp, + border = androidx.compose.foundation.BorderStroke( + if (isGraphWindow && SignalDragDropManager.isDragging) 2.dp else 1.dp, + if (isGraphWindow && SignalDragDropManager.isDragging) Color(0xFF2563EB) else Color(0xFFB0B8C4) + ), + modifier = Modifier + .zIndex(window.zIndex) + .offset { + IntOffset( + (window.offsetX * density).toInt(), + (window.offsetY * density).toInt() + ) + } + .size(window.width.dp, (if (window.isMinimized) 32f else window.height).dp) + .nestedScroll(stopNestedScrollConnection) + .onGloballyPositioned { coordinates -> + windowPositionInRoot = coordinates.positionInRoot() + if (isGraphWindow) { + SignalDragDropManager.registerDropTarget(window.id, coordinates.boundsInRoot()) + } + } + .bringWindowToFrontOnPress(window.id, onBringToFront) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Window Header (드래그하여 위치 이동 + 탭 시 포커스) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(28.dp) + .background(Color(0xFFEEF2F6)) + .border(1.dp, Color(0xFFC8CED8)) + .padding(horizontal = 8.dp) + .onGloballyPositioned { headerPositionInRoot = it.positionInRoot() } + .pointerInput(window.id) { + detectDragGestures( + onDragStart = { offset -> + currentOnDragStart(headerPositionInRoot - windowPositionInRoot + offset) + currentOnDragPointerPosition(headerPositionInRoot + offset) + }, + onDrag = { change, dragAmount -> + change.consume() + currentOnDragPointerPosition(headerPositionInRoot + change.position) + currentOnMove( + dragAmount.x / density, + dragAmount.y / density + ) + }, + onDragEnd = { currentOnDragEnd() }, + onDragCancel = { currentOnDragCancel() } + ) + } + ) { + val mdbName = viewModel.uiState.collectAsState().value.mdbFileName + val windowDisplayTitle = if (window.type == MdiWindowType.SIGNAL_LIST && mdbName != null) { + "${window.title} ($mdbName)" + } else { + window.title + } + Text( + text = windowDisplayTitle, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = ColorOnSurface, + modifier = Modifier.weight(1f) + ) + + val isSignalListWindow = window.type == MdiWindowType.SIGNAL_LIST + + // 헤더 컨트롤: [⚙️ 설정] + [◀] [▶] + [✕] + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + when (window.type) { + MdiWindowType.GRAPH, MdiWindowType.MULTI_AXIS_GRAPH, MdiWindowType.CHART -> { + val windowRenderer = remember(window.id) { viewModel.getChartRenderer(window.id) } + val windowSnapshot by windowRenderer.renderSnapshotFlow.collectAsState() + var isHeaderRenameDialogOpen by remember { mutableStateOf(false) } + + GraphSettingsButton( + snapshot = windowSnapshot, + isMultiAxis = (window.type == MdiWindowType.MULTI_AXIS_GRAPH), + onRemoveSignal = { signalId -> viewModel.unbindChartSignal(signalId, window.id) }, + onClearAll = { viewModel.clearChartSignals(window.id) }, + onToggleVisibility = { signalId -> viewModel.toggleChartSignalVisibility(signalId, window.id) }, + onChangeThickness = { signalId, th -> viewModel.setChartSignalThickness(signalId, th, window.id) }, + onChangeDashStyle = { signalId, st -> windowRenderer.setSeriesDashStyle(signalId, st) }, + onSelectLegendPosition = { pos -> windowRenderer.setLegendPosition(pos) }, + onSelectTimePreset = { preset -> viewModel.setChartTimeWindowPreset(preset, window.id) }, + onSelectXAxisManual = { windowRenderer.setXAxisManualMode() }, + onSelectYAxisAuto = { windowRenderer.setYAxisAuto(true) }, + onOpenYAxisSetting = { series -> editingSeriesForYAxis = series }, + onChangeTitleName = { isHeaderRenameDialogOpen = true }, + onQuitWindow = { currentOnClose() } + ) + + if (isHeaderRenameDialogOpen) { + RenameWindowDialog( + currentTitle = window.title, + onConfirm = { newTitle -> + viewModel.mdiViewModel.renameWindow(window.id, newTitle) + isHeaderRenameDialogOpen = false + }, + onDismiss = { isHeaderRenameDialogOpen = false } + ) + } + } + MdiWindowType.ALPHANUMERICAL -> { + val currentDb = viewModel.uiState.collectAsState().value.mdbDatabase + val activeMdbSignals = currentDb?.allSignals ?: mdbSignals + val formulas by viewModel.formulas.collectAsState() + val modules by viewModel.sensorModules.collectAsState() + var renameAlpha by remember { mutableStateOf(false) } + val alphaDisplaySignals = resolveAlphaSignals(window.config.targetSignals, canSignals, activeMdbSignals, a2lSignals, formulas, modules) + if (renameAlpha) RenameWindowDialog(window.title, { + viewModel.mdiViewModel.renameWindow(window.id, it); renameAlpha = false + }, { renameAlpha = false }) + AlphanumericalSettingsButton( + onRename = { renameAlpha = true }, + displaySignals = alphaDisplaySignals, + isTileMode = isTileMode, + onToggleViewMode = { + viewModel.mdiViewModel.updateWindowConfig(window.id, window.config.copy(isTileMode = !isTileMode)) + }, + onRemoveSignal = { sigName -> viewModel.removeAlphanumericalSignal(window.id, sigName) }, + onClearAll = { viewModel.clearAlphanumericalSignals(window.id) }, + hiddenSignalIds = hiddenAlphaSignals, + onToggleVisibility = { sigName -> + val legacyName = alphaDisplaySignals.find { it.id == sigName }?.definition?.name + val wasHidden = sigName in hiddenAlphaSignals || legacyName in hiddenAlphaSignals + val base = hiddenAlphaSignals - listOfNotNull(sigName, legacyName).toSet() + val hidden = if (wasHidden) base else base + sigName + viewModel.mdiViewModel.updateWindowConfig(window.id, window.config.copy(hiddenSignalIds = hidden)) + }, + highlightColors = highlightColors, + onChangeHighlight = { sigName, cVal -> + viewModel.mdiViewModel.updateWindowConfig(window.id, + window.config.copy(highlightColors = highlightColors + (sigName to cVal))) + } + ) + } + else -> {} + } + if (!isSignalListWindow) { + // 이전 페이지로 창 이동 ◀ + Box( + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(2.dp)) + .clickable { viewModel.moveWindowToPreviousPage(window.id) }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.ChevronLeft, + contentDescription = "Move Window to Previous Page", + tint = Color(0xFF475569), + modifier = Modifier.size(16.dp) + ) + } + + // 다음 페이지로 창 이동 ▶ + Box( + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(2.dp)) + .clickable { viewModel.moveWindowToNextPage(window.id) }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Move Window to Next Page", + tint = Color(0xFF475569), + modifier = Modifier.size(16.dp) + ) + } + } + + if (window.isClosable) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(2.dp)) + .clickable { currentOnClose() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close Window", + tint = ColorSecondary, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + + // Window Body (타입별 콘텐츠) + if (!window.isMinimized) Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color.White) + ) { + when (window.type) { + MdiWindowType.SIGNAL_LIST -> { + SignalListWindowContent( + mdbDatabase = viewModel.uiState.collectAsState().value.mdbDatabase, + canSignals = canSignals, + a2lSignals = a2lSignals, + onSignalClick = { _, _ -> + // 단순 항목 선택 (그래프 창 자동 생성하지 않음) + }, + onCategoryClick = { categoryId -> + when (categoryId) { + "category_mdb_signal" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.MDB_SIGNAL_LIST, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + "category_can_signal" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.CAN_SIGNAL_LIST, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + "category_a2l_measurements", "category_a2l" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.A2L_MEASUREMENTS, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + "category_a2l_characteristics" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.A2L_CHARACTERISTICS, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + "category_a2l_characteristics_array" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.A2L_CHARACTERISTICS_ARRAY, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + "category_formula", "root_formula" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.FORMULA_LIST, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + "category_constant_signal" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.CONSTANT_SIGNAL_LIST, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + "category_compensation_type_a" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.COMPENSATION_TYPE_A, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + "category_compensation_type_b" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.COMPENSATION_TYPE_B, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + "category_sensor_module", "root_sensor_module" -> viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.SENSOR_MODULE_LIST, + viewportScrollX = viewportScrollX, + viewportScrollY = viewportScrollY + ) + } + } + ) + } + MdiWindowType.MDB_SIGNAL_LIST -> { + val db = viewModel.uiState.collectAsState().value.mdbDatabase + com.dct.app.presentation.signaltree.WindowsClassicMdbSignalTableWindow( + database = db, + liveTelemetryValues = liveTelemetryValues, + onSignalClick = { _ -> + // 단순 선택 (그래프 자동 켜짐 방지) + }, + onSignalDrop = { sig -> + viewModel.handleUniversalSignalDrop( + signal = DraggedSignal( + signalId = "sig_${sig.reclid}_${sig.idx}", + signalName = sig.name, + colorLong = sig.color, + unit = sig.unit, + isCan = false, + rawSignal = sig + ), + targetWindowId = SignalDragDropManager.lastDropTargetId + ) + }, + onColorChange = { sig, newColor -> + viewModel.updateMdbSignalColor(context, sig, newColor) + }, + onDescriptionChange = { sig, newDesc -> + viewModel.updateMdbSignalDescription(context, sig.name, newDesc) + } + ) + } + MdiWindowType.CAN_SIGNAL_LIST -> { + com.dct.app.presentation.signaltree.WindowsClassicCanSignalTableWindow( + canSignals = canSignals, + liveTelemetryValues = liveTelemetryValues, + onSignalClick = { _ -> + // 단순 선택 (그래프 자동 켜짐 방지) + }, + onSignalDrop = { sig -> + viewModel.handleUniversalSignalDrop( + signal = DraggedSignal( + signalId = "can_${sig.idx}", + signalName = sig.name, + colorLong = sig.color, + unit = sig.unit, + isCan = true, + rawSignal = sig + ), + targetWindowId = SignalDragDropManager.lastDropTargetId + ) + }, + onColorChange = { sig, newColor -> + viewModel.updateCanSignalColor(context, sig, newColor) + }, + onDescriptionChange = { sig, newDesc -> + viewModel.updateCanSignalDescription(context, sig.name, newDesc) + } + ) + } + MdiWindowType.A2L_MEASUREMENTS, MdiWindowType.A2L_SIGNAL_LIST -> { + com.dct.app.presentation.signaltree.WindowsClassicA2lSignalTableWindow( + a2lSignals = a2lSignals, + liveTelemetryValues = liveTelemetryValues, + onSignalClick = { _ -> + // 단순 선택 (그래프 자동 켜짐 방지) + }, + onSignalDrop = { sig -> + viewModel.handleUniversalSignalDrop( + signal = DraggedSignal( + signalId = "a2l_${sig.adl}", + signalName = sig.name, + colorLong = sig.color, + unit = sig.unit, + isCan = false, + isA2l = true, + rawSignal = sig + ), + targetWindowId = SignalDragDropManager.lastDropTargetId + ) + }, + onColorChange = { sig, newColor -> + viewModel.updateA2lSignalColor(context, sig, newColor) + }, + onDescriptionChange = { sig, newDesc -> + viewModel.updateA2lSignalDescription(context, sig.name, newDesc) + } + ) + } + MdiWindowType.A2L_CHARACTERISTICS -> { + com.dct.app.presentation.signaltree.WindowsClassicA2lCharacteristicTableWindow( + characteristics = a2lCharacteristics, + onDescriptionChange = { item, text -> viewModel.updateParameterDescription("characteristic", item.name, text, contentRevision) }, + onRead = { viewModel.readA2lCharacteristic(it.name) }, + onValueChange = { char, newVal -> + viewModel.updateA2lCharacteristicValue(char.name, newVal) + } + ) + } + MdiWindowType.A2L_CHARACTERISTICS_ARRAY -> { + com.dct.app.presentation.signaltree.WindowsClassicA2lCharacteristicArrayTableWindow( + characteristicsArray = a2lCharacteristicsArray, + onDescriptionChange = { item, text -> viewModel.updateParameterDescription("array", item.name, text, contentRevision) }, + onReadCell = { array, index -> viewModel.readA2lCharacteristic(array.name, index) }, + onCellChange = { arr, idx, newVal -> + viewModel.updateA2lCharacteristicArrayCell(arr.name, idx, newVal) + } + ) + } + MdiWindowType.FORMULA_LIST -> { + val formulas by viewModel.formulas.collectAsState() + FormulaWindowContent( + formulas = formulas, + liveTelemetryValues = liveTelemetryValues, + onAddFormula = { f -> viewModel.addFormula(f) }, + onUpdateFormula = { f -> viewModel.updateFormula(f) }, + onDeleteFormula = { f -> viewModel.deleteFormula(f) }, + onSignalDrop = { sig -> + viewModel.handleUniversalSignalDrop( + signal = sig, + targetWindowId = SignalDragDropManager.lastDropTargetId + ) + } + ) + } + MdiWindowType.CONSTANT_SIGNAL_LIST -> { + val constantUi = viewModel.uiState.collectAsState().value + val busy = viewModel.isCalibrationBusy.collectAsState().value + val connection = viewModel.packetRepository.connectionState.collectAsState().value + androidx.compose.runtime.key(contentRevision) { + com.dct.app.presentation.signaltree.WindowsClassicConstantTableWindow( + constants = constantUi.mdbDatabase?.constants ?: emptyList(), + canRequestDevice = connection == com.dct.app.domain.repository.DeviceConnectionState.READY && !constantUi.isMeasuring, + isCalibrationBusy = busy, + onDescriptionChange = { item, text -> viewModel.updateParameterDescription("constant", item.name, text, contentRevision) }, + onRead = { item, done -> viewModel.readMdbConstant(item.name, done, contentRevision) }, + onValueChange = { con, newVal, done -> + viewModel.updateMdbConstantValue(con.name, newVal, done, contentRevision) + } + ) + } + } + MdiWindowType.COMPENSATION_TYPE_A -> { + val uiState = viewModel.uiState.collectAsState().value + val db = uiState.mdbDatabase + com.dct.app.presentation.signaltree.WindowsClassicCompensationTableWindow( + maps = db?.compensationMaps?.filter { it.isTypeA } ?: emptyList(), + isTypeA = true, + isConnected = uiState.isConnected, + onDescriptionChange = { item, text -> viewModel.updateParameterDescription("map_${item.type.name}", item.yName, text, contentRevision) }, + onReadX = { map -> viewModel.readCompensationMapX(map) }, + onReadY = { map -> viewModel.readCompensationMapY(map) }, + onWriteX = { map -> viewModel.writeCompensationMapX(map) }, + onWriteY = { map -> viewModel.writeCompensationMapY(map) } + ) + } + MdiWindowType.COMPENSATION_TYPE_B -> { + val uiState = viewModel.uiState.collectAsState().value + val db = uiState.mdbDatabase + com.dct.app.presentation.signaltree.WindowsClassicCompensationTableWindow( + maps = db?.compensationMaps?.filter { it.isTypeB } ?: emptyList(), + isTypeA = false, + isConnected = uiState.isConnected, + onDescriptionChange = { item, text -> viewModel.updateParameterDescription("map_${item.type.name}", item.yName, text, contentRevision) }, + onReadX = { map -> viewModel.readCompensationMapX(map) }, + onReadY = { map -> viewModel.readCompensationMapY(map) }, + onWriteX = { map -> viewModel.writeCompensationMapX(map) }, + onWriteY = { map -> viewModel.writeCompensationMapY(map) } + ) + } + MdiWindowType.SENSOR_MODULE_LIST -> { + val sensorModules by viewModel.sensorModules.collectAsState() + SensorModuleWindowContent( + onDescriptionChange = { module, channel, text -> viewModel.updateSensorModuleDescription(module, channel, text, contentRevision) }, + sensorModules = sensorModules, + liveTelemetryValues = liveTelemetryValues, + onUpdateSensorModules = { mods -> viewModel.updateSensorModules(mods) }, + onSignalDrop = { sig -> + viewModel.handleUniversalSignalDrop( + signal = sig, + targetWindowId = SignalDragDropManager.lastDropTargetId + ) + } + ) + } + MdiWindowType.GRAPH, MdiWindowType.MULTI_AXIS_GRAPH, MdiWindowType.CHART -> { + val windowRenderer = remember(window.id) { viewModel.getChartRenderer(window.id) } + val windowSnapshot by windowRenderer.renderSnapshotFlow.collectAsState() + var editingSeriesForYAxis by remember { mutableStateOf(null) } + var isYAxisDialogOpen by remember { mutableStateOf(false) } + var isRenameDialogOpen by remember { mutableStateOf(false) } + + GraphWindowContent( + onCrosshairTimeChange = { windowRenderer.setCrosshairTimeSec(it) }, + snapshot = windowSnapshot, + windowId = window.id, + isMultiAxis = (window.type == MdiWindowType.MULTI_AXIS_GRAPH), + onRemoveSignal = { signalId -> viewModel.unbindChartSignal(signalId, window.id) }, + onClearAll = { viewModel.clearChartSignals(window.id) }, + onPanTime = { deltaSec -> windowRenderer.panTimeWindow(deltaSec) }, + onZoomTime = { factor, anchor -> windowRenderer.zoomTimeWindow(factor, anchor) }, + onResetPan = { windowRenderer.resetPan() }, + onSignalDrop = { sig -> + viewModel.handleUniversalSignalDrop( + signal = sig, + targetWindowId = window.id + ) + }, + onToggleVisibility = { signalId -> + val target = windowSnapshot.seriesList.find { it.signalId == signalId } + if (target != null) { + windowRenderer.setSeriesVisible(signalId, !target.isVisible) + } + }, + onChangeThickness = { signalId, th -> + windowRenderer.setSeriesThickness(signalId, th) + }, + onChangeDashStyle = { signalId, st -> + windowRenderer.setSeriesDashStyle(signalId, st) + }, + onSelectLegendPosition = { pos -> + windowRenderer.setLegendPosition(pos) + }, + onSelectTimePreset = { preset -> + windowRenderer.setTimeWindow(preset) + }, + onSelectXAxisManual = { + windowRenderer.setXAxisManualMode() + }, + onSelectYAxisAuto = { + windowRenderer.setYAxisAuto(true) + }, + onOpenYAxisSetting = { series -> + editingSeriesForYAxis = series + isYAxisDialogOpen = true + }, + onChangeTitleName = { + isRenameDialogOpen = true + }, + onQuitWindow = { + currentOnClose() + } + ) + + // Y축 수동 범위 설정 다이얼로그 + if (isYAxisDialogOpen) { + val s = editingSeriesForYAxis + val targetPosition = s?.axisPosition ?: com.dct.app.domain.model.chart.AxisPosition.LEFT_0 + val curAxis = windowSnapshot.axesConfig[targetPosition] + GraphYAxisDialog( + signalName = s?.signalName ?: "Primary Y-Axis (L1)", + currentMin = curAxis?.minVal ?: -50.0, + currentMax = curAxis?.maxVal ?: 100.0, + isAuto = curAxis?.isAuto ?: true, + onDismiss = { + isYAxisDialogOpen = false + editingSeriesForYAxis = null + }, + onConfirm = { minVal, maxVal, isAuto -> + windowRenderer.setAxisRange(targetPosition, minVal, maxVal, isAuto) + isYAxisDialogOpen = false + editingSeriesForYAxis = null + } + ) + } + + // 창 이름 변경 다이얼로그 + if (isRenameDialogOpen) { + RenameWindowDialog( + currentTitle = window.title, + onConfirm = { newTitle -> + viewModel.mdiViewModel.renameWindow(window.id, newTitle) + isRenameDialogOpen = false + }, + onDismiss = { isRenameDialogOpen = false } + ) + } + } + MdiWindowType.ALPHANUMERICAL -> { + val currentDb = viewModel.uiState.collectAsState().value.mdbDatabase + val activeMdbSignals = currentDb?.allSignals ?: mdbSignals + val formulas by viewModel.formulas.collectAsState() + val sensorModules by viewModel.sensorModules.collectAsState() + AlphanumericalWindowContent( + windowId = window.id, + pinnedSignalIds = window.config.targetSignals, + liveTelemetryValues = liveTelemetryValues, + canSignals = canSignals, + mdbSignals = activeMdbSignals, + a2lSignals = a2lSignals, + formulas = formulas, + sensorModules = sensorModules, + isTileMode = isTileMode, + hiddenSignalIds = hiddenAlphaSignals, + highlightColors = highlightColors, + onAddSignal = { signalId -> viewModel.addAlphanumericalSignal(window.id, signalId) }, + onRemoveSignal = { signalId -> viewModel.removeAlphanumericalSignal(window.id, signalId) }, + onClearAll = { viewModel.clearAlphanumericalSignals(window.id) }, + onSignalDrop = { droppedSignal -> + viewModel.handleUniversalSignalDrop(droppedSignal, SignalDragDropManager.lastDropTargetId) + } + ) + } + else -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("${window.title} (준비 중)", fontSize = 12.sp, color = ColorSecondary) + } + } + } + + // 우측 하단 크기 조절 핸들 (Resize Grip - 32dp 넓은 터치 영역 지원) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(32.dp) + .pointerInput(window.id) { + detectDragGestures( + onDrag = { change, dragAmount -> + change.consume() + currentOnResize( + dragAmount.x / density, + dragAmount.y / density + ) + } + ) + }, + contentAlignment = Alignment.BottomEnd + ) { + Icon( + imageVector = Icons.Default.SouthEast, + contentDescription = "Resize", + tint = Color(0xFF6C757D), + modifier = Modifier + .padding(4.dp) + .size(16.dp) + ) + } + } + } + } + } +} + +/** + * PC MFC CInputTitleDlg 호환 창 제목 변경 다이얼로그 + */ +@Composable +fun RenameWindowDialog( + currentTitle: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + var text by remember { mutableStateOf(currentTitle) } + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("Change Title Name", fontWeight = FontWeight.Bold, fontSize = 16.sp) + }, + text = { + Column { + Text("Enter new window title:", fontSize = 13.sp, color = Color.DarkGray) + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.OutlinedTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + androidx.compose.material3.Button( + onClick = { + if (text.isNotBlank()) { + onConfirm(text.trim()) + } + } + ) { + Text("OK") + } + }, + dismissButton = { + androidx.compose.material3.OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MonitorDesignTokens.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MonitorDesignTokens.kt new file mode 100644 index 0000000..e7493a5 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MonitorDesignTokens.kt @@ -0,0 +1,25 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.ui.graphics.Color + +// Stitch & Windows Classic Monitor Design Tokens +val ColorBackground = Color(0xFFF8F9FB) +val ColorSurface = Color(0xFFF8F9FB) +val ColorSurfaceBright = Color(0xFFF8F9FB) +val ColorSurfaceContainerLowest = Color(0xFFFFFFFF) +val ColorSurfaceContainerLow = Color(0xFFF3F4F6) +val ColorSurfaceContainer = Color(0xFFEDEEF0) +val ColorSurfaceContainerHigh = Color(0xFFE7E8EA) +val ColorSurfaceContainerHighest = Color(0xFFE1E2E4) +val ColorSurfaceVariant = Color(0xFFE1E2E4) +val ColorPrimary = Color(0xFF003D9B) +val ColorPrimaryContainer = Color(0xFF0052CC) +val ColorPrimaryFixedDim = Color(0xFFB2C5FF) +val ColorOnPrimary = Color(0xFFFFFFFF) +val ColorOnSurface = Color(0xFF191C1E) +val ColorOnSurfaceVariant = Color(0xFF434654) +val ColorOutlineVariant = Color(0xFFC3C6D6) +val ColorSecondary = Color(0xFF535F73) +val ColorSecondaryContainer = Color(0xFFD4E0F8) +val ColorTextPrimary = Color(0xFF1E293B) +val ColorTextSecondary = Color(0xFF64748B) diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SensorModuleWindowView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SensorModuleWindowView.kt new file mode 100644 index 0000000..d22aa2f --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SensorModuleWindowView.kt @@ -0,0 +1,354 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.project.DpcSensorModuleChannel +import com.dct.app.domain.model.project.DpcSensorModuleConfig +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.SignalDragDropManager +import com.dct.app.presentation.project.SensorModuleConfigDialog +import com.dct.app.presentation.signaltree.TableSubHeader +import java.util.Locale +import com.dct.app.presentation.signaltree.rememberWindowString +import com.dct.app.presentation.signaltree.rememberWindowInt + +/** + * Sensor Module (외부 센서 모듈) 실시간 모니터링 MDI 창 컴포넌트 (PC ReportSensorModuleFormView 규격) + */ +@Composable +fun SensorModuleWindowContent( + sensorModules: List, + liveTelemetryValues: Map = emptyMap(), + onUpdateSensorModules: (List) -> Unit = {}, + onSignalDrop: (DraggedSignal) -> Unit = {}, + onChannelClick: (DpcSensorModuleChannel) -> Unit = {}, + onDescriptionChange: (Int, Int, String) -> Unit = { _, _, _ -> } +) { + var searchQuery by rememberWindowString("search") + var selectedModuleFilter by rememberWindowInt("module_filter", -1) // -1: All, 0: SM 1, 1: SM 2, 2: SM 3, 3: SM 4 + var isConfigDialogOpen by remember { mutableStateOf(false) } + + // 활성화된 모듈의 모든 채널 목록 취합 + val allActiveChannels = remember(sensorModules, selectedModuleFilter, searchQuery) { + val filteredModules = if (selectedModuleFilter == -1) { + sensorModules.filter { it.isUsed } + } else { + sensorModules.filter { it.moduleIndex == selectedModuleFilter && it.isUsed } + } + + val channels = mutableListOf>() + for (mod in filteredModules) { + for (ch in mod.channels) { + if (searchQuery.isBlank() || + ch.name.contains(searchQuery, ignoreCase = true) || + ch.description.contains(searchQuery, ignoreCase = true) || + ch.unit.contains(searchQuery, ignoreCase = true) || + (if (ch.isVoltage) "Voltage" else "Temperature").contains(searchQuery, ignoreCase = true) + ) { + channels.add(mod.moduleIndex to ch) + } + } + } + channels + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + // 상단 컨트롤 바: 검색창, 모듈 필터 탭, 설정 버튼 + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF1F3F5)) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box(modifier = Modifier.weight(1f)) { + TableSubHeader( + searchQuery = searchQuery, + onSearchChange = { searchQuery = it } + ) + } + Spacer(modifier = Modifier.width(6.dp)) + + // SM 탭 필터 (All, SM 1..4) + Row(verticalAlignment = Alignment.CenterVertically) { + listOf(-1 to "All", 0 to "SM 1", 1 to "SM 2", 2 to "SM 3", 3 to "SM 4").forEach { (idx, label) -> + val isSelected = selectedModuleFilter == idx + val isUsed = idx == -1 || (sensorModules.getOrNull(idx)?.isUsed == true) + Surface( + shape = RoundedCornerShape(3.dp), + color = if (isSelected) Color(0xFF1E88E5) else if (isUsed) Color.White else Color(0xFFE9ECEF), + border = androidx.compose.foundation.BorderStroke(1.dp, if (isSelected) Color(0xFF1E88E5) else Color(0xFFCED4DA)), + modifier = Modifier + .padding(horizontal = 1.dp) + .height(26.dp) + .clickable { selectedModuleFilter = idx } + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 6.dp)) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) Color.White else if (isUsed) Color(0xFF212529) else Color(0xFFADB5BD) + ) + } + } + } + } + + Spacer(modifier = Modifier.width(6.dp)) + + // SM Config 버튼 + Button( + onClick = { isConfigDialogOpen = true }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF455A64)), + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp), + shape = RoundedCornerShape(4.dp), + modifier = Modifier.height(26.dp) + ) { + Icon(Icons.Default.Settings, contentDescription = "Config", modifier = Modifier.size(13.dp), tint = Color.White) + Spacer(modifier = Modifier.width(3.dp)) + Text("Config", fontSize = 11.sp, color = Color.White, fontWeight = FontWeight.Bold) + } + } + + // Table Header & Rows + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + Column(modifier = Modifier.width(520.dp).fillMaxHeight()) { + // Table Header + Row( + modifier = Modifier + .fillMaxWidth() + .height(26.dp) + .background(Color(0xFFE9ECEF)) + .border(1.dp, Color(0xFFCED4DA)), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Sensor module name", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(120.dp).padding(horizontal = 6.dp)) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) + Text("Value", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(70.dp).padding(horizontal = 6.dp)) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) + Text("Unit", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(45.dp).padding(horizontal = 6.dp)) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) + Text("Type", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(85.dp).padding(horizontal = 6.dp)) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) + Text("Color", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.width(55.dp).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) + Text("Description", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF212529), modifier = Modifier.weight(1f).padding(horizontal = 6.dp)) + } + + // Table Body + if (allActiveChannels.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Default.Sensors, contentDescription = null, tint = Color(0xFFADB5BD), modifier = Modifier.size(32.dp)) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (sensorModules.none { it.isUsed }) "활성화된 Sensor Module이 없습니다.\n상단 [Config] 버튼을 눌러 모듈을 활성화하세요." else "검색 결과가 없습니다.", + fontSize = 12.sp, + color = Color(0xFF6C757D), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + itemsIndexed(allActiveChannels, key = { _, (mIdx, ch) -> "sm_${mIdx}_${ch.channelIndex}" }) { index, (mIdx, ch) -> + var itemBounds by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } + + // 실시간 텔레메트리 조회 (ch.name 또는 sm_01_01) + val modNum = String.format(Locale.US, "%02d", mIdx + 1) + val chNum = String.format(Locale.US, "%02d", ch.channelIndex + 1) + val sigId = "sm_${modNum}_${chNum}" + val liveVal = liveTelemetryValues[ch.name] ?: liveTelemetryValues[sigId] + + val isNc = liveVal == null || liveVal.isNaN() || liveVal == -999999.0 + val valStr = if (isNc) "NC" else String.format(Locale.US, "%.2f", liveVal) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(26.dp) + .background(if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) + .border(0.5.dp, Color(0xFFE9ECEF)) + .onGloballyPositioned { coords -> + itemBounds = coords.boundsInRoot() + } + .pointerInput(ch) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + SignalDragDropManager.startDrag( + DraggedSignal( + signalId = sigId, + signalName = ch.name, + colorLong = ch.color, + unit = ch.unit, + isCan = false, + isA2l = false + ), + itemBounds.topLeft + localOffset + ) + }, + onDrag = { change, dragAmount -> + change.consume() + SignalDragDropManager.updateDrag(dragAmount) + }, + onDragEnd = { + SignalDragDropManager.endDrag { droppedSignal -> + onSignalDrop(droppedSignal) + } + }, + onDragCancel = { + SignalDragDropManager.cancelDrag() + } + ) + } + .clickable { onChannelClick(ch) }, + verticalAlignment = Alignment.CenterVertically + ) { + // 1. Name & Indicator + Row( + modifier = Modifier.width(120.dp).padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(7.dp) + .background(Color(ch.color), RoundedCornerShape(2.dp)) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = ch.name, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF212529), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) + + // 2. Value (NC or %.2f) + Text( + text = valStr, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = if (isNc) Color(0xFF9E9E9E) else Color(0xFF0F172A), + modifier = Modifier.width(70.dp).padding(horizontal = 6.dp), + maxLines = 1 + ) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) + + // 3. Unit + Text( + text = ch.unit, + fontSize = 11.sp, + color = Color(0xFF495057), + modifier = Modifier.width(45.dp).padding(horizontal = 6.dp), + maxLines = 1 + ) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) + + // 4. Type (Temperature / Voltage) + Text( + text = if (ch.isVoltage) "Voltage" else "Temperature", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = if (ch.isVoltage) Color(0xFFB78103) else Color(0xFF198754), + modifier = Modifier.width(85.dp).padding(horizontal = 6.dp), + maxLines = 1 + ) + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) + + // 5. Color Hex + Row( + modifier = Modifier.width(55.dp).padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(Color(ch.color), CircleShape) + .border(0.5.dp, Color(0xFFCED4DA), CircleShape) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = String.format(Locale.US, "%06X", ch.color and 0xFFFFFFL), + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + color = Color(0xFF6C757D) + ) + } + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFE9ECEF))) + + // 6. Description + com.dct.app.presentation.signaltree.TableEditableDescriptionCell( + ch.description, { description -> + onDescriptionChange(mIdx, ch.channelIndex, description) + }, Modifier.weight(1f) + ) + } + } + } + } + } + } + } + + // 센서 모듈 설정 대화상자 + if (isConfigDialogOpen) { + SensorModuleConfigDialog( + initialModules = sensorModules, + onDismiss = { isConfigDialogOpen = false }, + onConfirm = { updatedModules -> + onUpdateSensorModules(updatedModules) + isConfigDialogOpen = false + } + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SignalListWindowView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SignalListWindowView.kt new file mode 100644 index 0000000..bdb8af9 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/SignalListWindowView.kt @@ -0,0 +1,53 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.presentation.signaltree.WindowsClassicSignalTreeView + +/** + * 신호 목록 트리 (Signal list) 윈도우 콘텐츠 컴포넌트 + */ +@Composable +fun SignalListWindowContent( + mdbDatabase: MdbDatabase?, + canSignals: List, + a2lSignals: List = emptyList(), + onSignalClick: (SignalDefinition, Boolean) -> Unit, + onCategoryClick: (String) -> Unit = {} +) { + if (mdbDatabase == null && canSignals.isEmpty() && a2lSignals.isEmpty()) { + // Figma Node 1:553 초기 상태: 완전히 비어있는 깨끗한 화이트 캔버스 + Box(modifier = Modifier.fillMaxSize().background(Color.White)) + } else { + var selectedNodeId by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + // Windows 클래식 계층 트리뷰 (CAN DBC / Formula / MDB / A2L / Sensor Module) + WindowsClassicSignalTreeView( + mdbDatabase = mdbDatabase, + canSignals = canSignals, + a2lSignals = a2lSignals, + selectedNodeId = selectedNodeId, + onSignalClick = { sig, isCan -> + selectedNodeId = if (isCan) "can_${sig.idx}_${sig.name}" else "mdb_sig_${sig.reclid}_${sig.idx}" + onSignalClick(sig, isCan) + }, + onCategoryClick = { categoryId -> + selectedNodeId = categoryId + onCategoryClick(categoryId) + }, + searchQuery = "", + modifier = Modifier.fillMaxSize() + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerFling.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerFling.kt new file mode 100644 index 0000000..db72750 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerFling.kt @@ -0,0 +1,39 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.unit.Velocity + +/** Track the stable two-pointer centroid, never the jumping centroid of a remaining finger. */ +internal class TwoFingerFling(private val maximumVelocity: Float) { + private val tracker = VelocityTracker() + private var lastSampleTime: Long? = null + private var releaseTime: Long? = null + private var releaseVelocity = Velocity.Zero + private var cancelled = false + + fun add(time: Long, center: Offset) { + if (cancelled || releaseTime != null) return + tracker.addPosition(time, center) + lastSampleTime = time + } + + fun release(time: Long, isPan: Boolean) { + if (cancelled || releaseTime != null) return + releaseTime = time + val last = lastSampleTime ?: return + // Same stopped-pointer interval used by Compose's VelocityTracker (40 ms). + if (isPan && time - last in 0L..40L) { + val limit = Velocity(maximumVelocity, maximumVelocity) + val velocity = tracker.calculateVelocity(limit) + releaseVelocity = Velocity(-velocity.x, -velocity.y) // Content drag -> scroll direction. + } + } + + fun cancel() { cancelled = true; releaseVelocity = Velocity.Zero } + + fun finish(time: Long): Velocity { + val released = releaseTime ?: return Velocity.Zero + return if (!cancelled && time - released in 0L..40L) releaseVelocity else Velocity.Zero + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerGesture.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerGesture.kt new file mode 100644 index 0000000..e130b98 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerGesture.kt @@ -0,0 +1,57 @@ +package com.dct.app.presentation.monitor.components + +import kotlin.math.abs +import kotlin.math.hypot + +/** One decision per contact sequence: translation and pinch can never run together. */ +internal class TwoFingerGesture(private val slop: Float, private val canZoom: Boolean) { + enum class Mode { Pending, Pan, Zoom, Ended } + data class Motion(val dx: Float = 0f, val dy: Float = 0f, val zoom: Float = 1f) + var mode = Mode.Pending + private set + private var startX = 0f + private var startY = 0f + private var startDistance = 0f + private var lastX = 0f + private var lastY = 0f + private var lastDistance = 0f + private var started = false + + fun end() { mode = Mode.Ended } + + fun update(x: Float, y: Float, distance: Float): Motion { + if (mode == Mode.Ended || !x.isFinite() || !y.isFinite() || !distance.isFinite()) return Motion() + if (!started) { + started = true + startX = x; startY = y; startDistance = distance + lastX = x; lastY = y; lastDistance = distance + return Motion() + } + val dx = x - startX + val dy = y - startY + val pan = hypot(dx, dy) + // Each finger moves by half the change in separation for a symmetric pinch. + val pinch = abs(distance - startDistance) / 2f + val wasPending = mode == Mode.Pending + if (wasPending) { + mode = when { + canZoom && pinch > slop && pinch > pan * 1.25f -> Mode.Zoom + pan > slop && (!canZoom || pan > pinch * 1.25f) -> Mode.Pan + else -> Mode.Pending + } + } + val result = when (mode) { + Mode.Pan -> if (wasPending) { + val ratio = (pan - slop) / pan + Motion(dx * ratio, dy * ratio) + } else Motion(x - lastX, y - lastY) + Mode.Zoom -> { + val base = if (wasPending) startDistance else lastDistance + Motion(zoom = if (base > 1f && distance > 1f) distance / base else 1f) + } + else -> Motion() + } + lastX = x; lastY = y; lastDistance = distance + return result + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WindowActivation.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WindowActivation.kt new file mode 100644 index 0000000..d652c02 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WindowActivation.kt @@ -0,0 +1,45 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.pointerInput + +/** Defer touch activation until a single-finger tap/drag is confirmed. Never consume input. */ +@Composable +internal fun Modifier.bringWindowToFrontOnPress(windowId: String, onActivate: () -> Unit): Modifier { + val currentOnActivate by rememberUpdatedState(onActivate) + val navigation = LocalWorkspaceTouchNavigation.current + return pointerInput(windowId, navigation) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + if (down.type != PointerType.Touch) { + if (navigation?.hadMultiplePointers != true) currentOnActivate() + return@awaitEachGesture + } + val activation = WindowActivationGesture() + activation.shouldActivate(navigation?.hadMultiplePointers == true || + currentEvent.changes.count { it.pressed } > 1, false) + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val multiple = navigation?.hadMultiplePointers == true || event.changes.count { it.pressed } > 1 + val change = event.changes.firstOrNull { it.id == down.id } + val releasedInside = change != null && change.previousPressed && !change.pressed && + change.position.x in 0f..size.width.toFloat() && change.position.y in 0f..size.height.toFloat() + // Activate on UP before a child button's click, but never after multi-touch. + if (activation.shouldActivate(multiple, releasedInside)) currentOnActivate() + if (event.changes.none { it.pressed }) break + val finalEvent = awaitPointerEvent(PointerEventPass.Final) + val moved = finalEvent.changes.firstOrNull { it.id == down.id } + val childStartedDrag = moved != null && moved.pressed && moved.previousPressed && + moved.isConsumed && moved.position != moved.previousPosition + if (activation.shouldActivate(multiple, childStartedDrag)) currentOnActivate() + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WindowActivationGesture.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WindowActivationGesture.kt new file mode 100644 index 0000000..f3c9405 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WindowActivationGesture.kt @@ -0,0 +1,14 @@ +package com.dct.app.presentation.monitor.components + +/** A touch DOWN alone cannot distinguish a tap from the start of a two-finger gesture. */ +internal class WindowActivationGesture { + private var suppressed = false + private var activated = false + + fun shouldActivate(multiplePointers: Boolean, singlePointerCommitted: Boolean): Boolean { + if (multiplePointers) suppressed = true + if (suppressed || activated || !singlePointerCommitted) return false + activated = true + return true + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspacePageTabBar.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspacePageTabBar.kt new file mode 100644 index 0000000..e51ae16 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspacePageTabBar.kt @@ -0,0 +1,160 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.WorkspacePageState + +/** + * 워크스페이스 멀티 탭 바 컴포넌트 + */ +@Composable +fun WorkspacePageTabBar( + pages: List, + activePageId: String, + onSwitchPage: (String) -> Unit, + onAddPage: () -> Unit, + onRemovePage: (String) -> Unit, + dragHoverPageId: String? = null, + onPageBoundsChanged: (String, Rect?) -> Unit = { _, _ -> } +) { + val currentOnPageBoundsChanged by rememberUpdatedState(onPageBoundsChanged) + Surface( + color = ColorSurfaceContainerLow, + modifier = Modifier + .fillMaxWidth() + .height(34.dp) + .border(width = 1.dp, color = ColorOutlineVariant.copy(alpha = 0.5f)) + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val horizontalPadding = 4.dp + val tabSpacing = 3.dp + val availableWidth = (maxWidth - (horizontalPadding * 2)).coerceAtLeast(100.dp) + val singleTabWidth = (availableWidth - (tabSpacing * 4)) / 5 + + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = horizontalPadding) + ) { + pages.forEach { page -> + key(page.id) { + DisposableEffect(page.id) { + onDispose { currentOnPageBoundsChanged(page.id, null) } + } + val isSelected = page.id == activePageId + val isDragTarget = page.id == dragHoverPageId + + val tabBgColor = when { + isDragTarget -> Color(0xFFDCEAFF) + isSelected -> ColorSurfaceBright + else -> Color(0xFFEFF1F4) + } + val tabBorderColor = when { + isDragTarget -> ColorPrimary + isSelected -> ColorOutlineVariant + else -> ColorOutlineVariant.copy(alpha = 0.6f) + } + + Surface( + color = tabBgColor, + shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp), + modifier = Modifier + .height(30.dp) + .width(singleTabWidth) + .onGloballyPositioned { currentOnPageBoundsChanged(page.id, it.boundsInRoot()) } + .border( + width = if (isDragTarget) 2.dp else 1.dp, + color = tabBorderColor, + shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp) + ) + .clickable { onSwitchPage(page.id) } + ) { + Box(modifier = Modifier.fillMaxSize()) { + // 활성 탭 상단 액센트 인디케이터 (블루 바) + if (isSelected) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.5.dp) + .background(ColorPrimary, shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) + .align(Alignment.TopCenter) + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + ) { + Text( + text = page.title, + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) ColorPrimary else ColorOnSurfaceVariant, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + val isPage1 = page.id == "page_1" || page.title.equals("Page 1", ignoreCase = true) + if (!isPage1 && pages.size > 1) { + Box( + modifier = Modifier + .size(18.dp) + .clickable { onRemovePage(page.id) }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close Tab", + tint = if (isSelected) ColorPrimary.copy(alpha = 0.7f) else ColorSecondary, + modifier = Modifier.size(12.dp) + ) + } + } + } + } + } + Spacer(modifier = Modifier.width(tabSpacing)) + } + } + + // 최대 5개 페이지까지만 추가 허용 (5개 도달 시 5개 탭이 가로 전체를 100% 채움) + if (pages.size < 5) { + IconButton( + onClick = onAddPage, + modifier = Modifier.size(28.dp).padding(bottom = 2.dp) + ) { + Icon(Icons.Default.Add, contentDescription = "Add Page", tint = ColorSecondary, modifier = Modifier.size(16.dp)) + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt new file mode 100644 index 0000000..5e1fd96 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt @@ -0,0 +1,344 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.presentation.main.CommunicationType +import com.dct.app.presentation.main.MainUiState +import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.mdi.MdiWindowType +import com.dct.app.domain.model.chart.InterpolationMode + +/** + * 리본 툴바 버튼 컴포넌트 + */ +@Composable +fun RibbonButton( + icon: ImageVector, + tooltip: String, + tint: Color = ColorSecondary, + enabled: Boolean = true, + onClick: () -> Unit = {} +) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = icon, + contentDescription = tooltip, + tint = if (enabled) tint else ColorOutlineVariant.copy(alpha = 0.5f), + modifier = Modifier.size(17.dp) + ) + } +} + +/** + * 상단 도구 리본바 컴포넌트 (Figma 1-553 규격 호환) + */ +@Composable +fun WorkspaceRibbonBar( + viewModel: MainViewModel, + mainUiState: MainUiState, + isProjectLoaded: Boolean, + isPortrait: Boolean, + screenWidthDp: Float, + currentScrollXDp: Float, + currentScrollYDp: Float, + onOpenProjectClick: () -> Unit, + onSaveProjectClick: () -> Unit, + onSaveAsProjectClick: () -> Unit, + onNewProjectClick: () -> Unit, + onPropertyProjectClick: () -> Unit, + onWifiClick: () -> Unit, + onBluetoothClick: () -> Unit, + onMaintenanceClick: () -> Unit, + onDisconnectClick: () -> Unit = { viewModel.disconnectCommunication() } +) { + var showFileMenu by remember { mutableStateOf(false) } + + Surface( + color = Color.White, + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 6.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + // 0. More menu: PC file actions and global graph presentation. + Box { + RibbonButton( + icon = Icons.Default.MoreVert, + tooltip = "File / Graph Menu", + onClick = { showFileMenu = true } + ) + DropdownMenu( + expanded = showFileMenu, + onDismissRequest = { showFileMenu = false }, + modifier = Modifier.background(Color.White) + ) { + DropdownMenuItem( + text = { + Text( + text = "Open Project file", + fontSize = 13.sp, + color = ColorTextPrimary, + modifier = Modifier.padding(horizontal = 4.dp) + ) + }, + leadingIcon = { + Icon( + Icons.Default.FolderOpen, + contentDescription = "Open", + tint = ColorSecondary, + modifier = Modifier.size(16.dp) + ) + }, + onClick = { + showFileMenu = false + onOpenProjectClick() + } + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = ColorOutlineVariant.copy(alpha = 0.5f)) + DropdownMenuItem( + enabled = isProjectLoaded, + text = { + Text( + text = "Save Project file", + fontSize = 13.sp, + color = if (isProjectLoaded) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp) + ) + }, + leadingIcon = { + Icon( + Icons.Default.Save, + contentDescription = "Save", + tint = if (isProjectLoaded) ColorSecondary else ColorOutlineVariant, + modifier = Modifier.size(16.dp) + ) + }, + onClick = { + showFileMenu = false + onSaveProjectClick() + } + ) + DropdownMenuItem( + enabled = isProjectLoaded, + text = { + Text( + text = "Save as Project file", + fontSize = 13.sp, + color = if (isProjectLoaded) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp) + ) + }, + leadingIcon = { + Icon( + Icons.Default.SaveAs, + contentDescription = "Save As", + tint = if (isProjectLoaded) ColorSecondary else ColorOutlineVariant, + modifier = Modifier.size(16.dp) + ) + }, + onClick = { + showFileMenu = false + onSaveAsProjectClick() + } + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = ColorOutlineVariant.copy(alpha = 0.5f)) + DropdownMenuItem( + enabled = isProjectLoaded, + text = { + Text( + text = "Property for project", + fontSize = 13.sp, + color = if (isProjectLoaded) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp) + ) + }, + leadingIcon = { + Icon( + Icons.Default.Tune, + contentDescription = "Property", + tint = if (isProjectLoaded) ColorSecondary else ColorOutlineVariant, + modifier = Modifier.size(16.dp) + ) + }, + onClick = { + showFileMenu = false + onPropertyProjectClick() + } + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = ColorOutlineVariant.copy(alpha = 0.5f)) + Text( + text = "Graph Type", + fontSize = 12.sp, + color = ColorTextSecondary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + listOf("Linear" to InterpolationMode.LINEAR, "Pulse" to InterpolationMode.PWM_STEP).forEach { (label, mode) -> + DropdownMenuItem( + text = { Text(label, fontSize = 13.sp, color = ColorTextPrimary) }, + leadingIcon = { + RadioButton(selected = mainUiState.graphInterpolationMode == mode, onClick = null) + }, + onClick = { + viewModel.setGraphInterpolationMode(mode) + showFileMenu = false + } + ) + } + } + } + + VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 2.dp), color = ColorOutlineVariant) + + // Group 1: New / Open / Save + RibbonButton( + icon = Icons.Default.NoteAdd, + tooltip = "New Project", + onClick = onNewProjectClick + ) + RibbonButton( + icon = Icons.Default.FolderOpen, + tooltip = "Open", + onClick = onOpenProjectClick + ) + RibbonButton( + icon = Icons.Default.Save, + tooltip = "Save", + enabled = isProjectLoaded, + onClick = onSaveProjectClick + ) + + VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) + + // Group 2: Disconnect / Wifi / Bluetooth (통신 버튼) + val isCommConnected = mainUiState.isConnected && mainUiState.communicationType != CommunicationType.NONE + val isWifiConnected = isCommConnected && mainUiState.communicationType == CommunicationType.WIFI + val isBtConnected = isCommConnected && mainUiState.communicationType == CommunicationType.BLUETOOTH + + RibbonButton( + icon = Icons.Default.LinkOff, + tooltip = "통신 끊기 (Disconnect)", + enabled = isCommConnected, + tint = if (isCommConnected) Color(0xFFEF4444) else ColorSecondary, + onClick = onDisconnectClick + ) + RibbonButton( + icon = Icons.Default.Wifi, + tooltip = if (isWifiConnected) "Wi-Fi Direct 연결됨 (${mainUiState.connectedDeviceName})" else "Wi-Fi Direct", + enabled = (!isCommConnected || isWifiConnected), + tint = if (isWifiConnected) Color(0xFF2563EB) else ColorSecondary, + onClick = onWifiClick + ) + RibbonButton( + icon = Icons.Default.Bluetooth, + tooltip = if (isBtConnected) "Bluetooth 연결됨 (${mainUiState.connectedDeviceName})" else "Bluetooth 연결", + enabled = (!isCommConnected || isBtConnected), + tint = if (isBtConnected) Color(0xFF2563EB) else ColorSecondary, + onClick = onBluetoothClick + ) + + VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) + + // Group 3: Measure Control (통신 버튼 바로 옆 재생 / 멈춤 - 통신 연결 시에만 활성화) + val isMeasuring = mainUiState.isMeasuring + val canStartMeasure = isCommConnected && !isMeasuring + val canStopMeasure = isCommConnected && isMeasuring + + RibbonButton( + icon = Icons.Default.PlayArrow, + tooltip = if (isCommConnected) "계측 시작 (Start Measure)" else "통신 연결 필요 (Connect required)", + enabled = canStartMeasure, + tint = if (canStartMeasure) Color(0xFF16A34A) else ColorSecondary.copy(alpha = 0.4f), + onClick = { viewModel.startMeasure() } + ) + RibbonButton( + icon = Icons.Default.Stop, + tooltip = if (isCommConnected) "계측 정지 (Stop Measure)" else "통신 연결 필요 (Connect required)", + enabled = canStopMeasure, + tint = if (canStopMeasure) Color(0xFFDC2626) else ColorSecondary.copy(alpha = 0.4f), + onClick = { viewModel.stopMeasure() } + ) + // 유지보수 기능 UI 노출 플래그 (코드는 보존하고 UI만 임시 숨김 처리) + val enableMaintenanceUi = false + if (enableMaintenanceUi) { + RibbonButton( + icon = Icons.Default.Build, + tooltip = if (isWifiConnected && !isMeasuring) "장비 유지보수 (P3)" else "Wi-Fi READY 상태에서 계측을 정지해야 합니다", + enabled = isWifiConnected && !isMeasuring, + tint = Color(0xFFD97706), + onClick = onMaintenanceClick + ) + } + + VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) + + // Group 4: Graph / Alphanumerical / MultiAxisGraph (현재 뷰포트 화면 안으로 오픈) + RibbonButton( + icon = Icons.Default.BarChart, + tooltip = "New Graph Window", + onClick = { + val defaultW = if (isPortrait) (screenWidthDp - 24f).coerceIn(280f, 440f) else 480f + val defaultH = if (isPortrait) 280f else 320f + viewModel.addGraphWindow( + defaultWidth = defaultW, + defaultHeight = defaultH, + viewportScrollX = currentScrollXDp, + viewportScrollY = currentScrollYDp + ) + } + ) + RibbonButton( + icon = Icons.Default.GridView, + tooltip = "Alphanumerical", + onClick = { + val defaultW = if (isPortrait) (screenWidthDp - 24f).coerceIn(280f, 440f) else 380f + val defaultH = if (isPortrait) 300f else 340f + viewModel.addAlphanumericalWindow( + defaultWidth = defaultW, + defaultHeight = defaultH, + viewportScrollX = currentScrollXDp, + viewportScrollY = currentScrollYDp + ) + } + ) + RibbonButton( + icon = Icons.Default.OpenWith, + tooltip = "MultiAxisGraph", + onClick = { + val defaultW = if (isPortrait) (screenWidthDp - 24f).coerceIn(280f, 440f) else 480f + val defaultH = if (isPortrait) 280f else 320f + viewModel.mdiViewModel.openOrFocusCategoryWindow( + type = MdiWindowType.MULTI_AXIS_GRAPH, + title = "MultiAxisGraph", + defaultWidth = defaultW, + defaultHeight = defaultH, + viewportScrollX = currentScrollXDp, + viewportScrollY = currentScrollYDp + ) + } + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceScrollbars.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceScrollbars.kt new file mode 100644 index 0000000..4688fc4 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceScrollbars.kt @@ -0,0 +1,151 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * Android Material 3 모던 터치 스타일 가로 스크롤바 (슬림 캡슐형 인디케이터) + */ +@Composable +fun AndroidModernHorizontalScrollbar( + scrollState: ScrollState, + modifier: Modifier = Modifier +) { + val maxScrollPx = scrollState.maxValue.toFloat() + if (maxScrollPx <= 0f) return + + val coroutineScope = rememberCoroutineScope() + val density = LocalDensity.current.density + var isThumbPressed by remember { mutableStateOf(false) } + + Box( + modifier = modifier + .fillMaxWidth() + .height(14.dp) + .padding(horizontal = 16.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(if (isThumbPressed) 6.dp else 4.dp) + .clip(RoundedCornerShape(100.dp)) + .background(Color(0x1F64748B)) + ) { + val trackWidthPx = constraints.maxWidth.toFloat() + val totalContentPx = trackWidthPx + maxScrollPx + val thumbWidthPx = (trackWidthPx / totalContentPx * trackWidthPx).coerceIn(40f * density, trackWidthPx) + val availableTrackPx = trackWidthPx - thumbWidthPx + val scrollRatio = (scrollState.value.toFloat() / maxScrollPx).coerceIn(0f, 1f) + val thumbOffsetPx = scrollRatio * availableTrackPx + + // 안드로이드 캡슐형 썸 (Thumb) + Box( + modifier = Modifier + .offset { IntOffset(thumbOffsetPx.toInt(), 0) } + .width((thumbWidthPx / density).dp) + .fillMaxHeight() + .clip(RoundedCornerShape(100.dp)) + .background(if (isThumbPressed) Color(0xFF2563EB) else Color(0xFF94A3B8)) + .pointerInput(maxScrollPx) { + detectDragGestures( + onDragStart = { isThumbPressed = true }, + onDrag = { change, dragAmount -> + change.consume() + if (availableTrackPx > 0f) { + val deltaScroll = (dragAmount.x / availableTrackPx) * maxScrollPx + coroutineScope.launch { + scrollState.scrollTo( + (scrollState.value + deltaScroll).toInt().coerceIn(0, scrollState.maxValue) + ) + } + } + }, + onDragEnd = { isThumbPressed = false }, + onDragCancel = { isThumbPressed = false } + ) + } + ) + } + } +} + +/** + * Android Material 3 모던 터치 스타일 세로 스크롤바 (슬림 캡슐형 인디케이터) + */ +@Composable +fun AndroidModernVerticalScrollbar( + scrollState: ScrollState, + modifier: Modifier = Modifier +) { + val maxScrollPx = scrollState.maxValue.toFloat() + if (maxScrollPx <= 0f) return + + val coroutineScope = rememberCoroutineScope() + val density = LocalDensity.current.density + var isThumbPressed by remember { mutableStateOf(false) } + + Box( + modifier = modifier + .fillMaxHeight() + .width(14.dp) + .padding(vertical = 16.dp, horizontal = 2.dp), + contentAlignment = Alignment.Center + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxHeight() + .width(if (isThumbPressed) 6.dp else 4.dp) + .clip(RoundedCornerShape(100.dp)) + .background(Color(0x1F64748B)) + ) { + val trackHeightPx = constraints.maxHeight.toFloat() + val totalContentPx = trackHeightPx + maxScrollPx + val thumbHeightPx = (trackHeightPx / totalContentPx * trackHeightPx).coerceIn(40f * density, trackHeightPx) + val availableTrackPx = trackHeightPx - thumbHeightPx + val scrollRatio = (scrollState.value.toFloat() / maxScrollPx).coerceIn(0f, 1f) + val thumbOffsetPx = scrollRatio * availableTrackPx + + // 안드로이드 세로 캡슐형 썸 (Thumb) + Box( + modifier = Modifier + .offset { IntOffset(0, thumbOffsetPx.toInt()) } + .height((thumbHeightPx / density).dp) + .fillMaxWidth() + .clip(RoundedCornerShape(100.dp)) + .background(if (isThumbPressed) Color(0xFF2563EB) else Color(0xFF94A3B8)) + .pointerInput(maxScrollPx) { + detectDragGestures( + onDragStart = { isThumbPressed = true }, + onDrag = { change, dragAmount -> + change.consume() + if (availableTrackPx > 0f) { + val deltaScroll = (dragAmount.y / availableTrackPx) * maxScrollPx + coroutineScope.launch { + scrollState.scrollTo( + (scrollState.value + deltaScroll).toInt().coerceIn(0, scrollState.maxValue) + ) + } + } + }, + onDragEnd = { isThumbPressed = false }, + onDragCancel = { isThumbPressed = false } + ) + } + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigation.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigation.kt new file mode 100644 index 0000000..bf2e1cd --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigation.kt @@ -0,0 +1,158 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.Velocity +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** Scoped to one page. Only the graph receiving the first DOWN may register a pinch target. */ +internal class WorkspaceTouchNavigation { + var ownsTouches = false + private set + var hadMultiplePointers = false + private set + fun observePointerCount(count: Int) { + if (count > 1) hadMultiplePointers = true + } + var zoomAtRoot: ((Float, Offset) -> Unit)? = null + var canZoomAtRoot: ((Offset) -> Boolean)? = null + fun capture() { ownsTouches = true } + fun reset() { ownsTouches = false; hadMultiplePointers = false; zoomAtRoot = null; canZoomAtRoot = null } +} + +internal val LocalWorkspaceTouchNavigation = staticCompositionLocalOf { null } + +/** Must be outside both scroll modifiers, so pointer positions do not move with the content. */ +@Composable +internal fun Modifier.workspaceTouchNavigation( + navigation: WorkspaceTouchNavigation, + horizontal: ScrollState, + vertical: ScrollState, + horizontalFling: FlingBehavior = ScrollableDefaults.flingBehavior(), + verticalFling: FlingBehavior = ScrollableDefaults.flingBehavior(), + isDragActive: () -> Boolean = { false } +): Modifier { + val currentIsDragActive by rememberUpdatedState(isDragActive) + val scope = rememberCoroutineScope() + val origin = remember { arrayOf(Offset.Zero) } + return onGloballyPositioned { origin[0] = it.positionInRoot() } + .pointerInput(navigation, horizontal, vertical, horizontalFling, verticalFling) { + var flingJob: Job? = null + try { + awaitEachGesture { + navigation.reset() + var scrollLock: Job? = null + val fling = TwoFingerFling(viewConfiguration.maximumFlingVelocity) + try { + val down = awaitFirstDown(false, PointerEventPass.Initial) + flingJob?.cancel() // Any new press, including over a child window, stops inertia. + navigation.observePointerCount(currentEvent.changes.count { it.pressed }) + var blocked = down.type != PointerType.Touch + var gesture: TwoFingerGesture? = null + var pairIds: List? = null + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val pressed = event.changes.filter { it.pressed } + // Shared history also covers fingers landing on different window hit paths. + navigation.observePointerCount(pressed.size) + if (!navigation.ownsTouches) { + if (currentIsDragActive() || pressed.any { it.type != PointerType.Touch }) blocked = true + if (!blocked && pressed.size == 2) { + navigation.capture() + pairIds = pressed.map { it.id } + val canZoom = navigation.zoomAtRoot != null && pressed.all { + navigation.canZoomAtRoot?.invoke(origin[0] + it.position) == true + } + gesture = TwoFingerGesture(viewConfiguration.touchSlop, canZoom) + // Cancel an earlier canvas fling and reserve both axes for this gesture. + scrollLock = scope.launch(start = CoroutineStart.UNDISPATCHED) { + horizontal.scroll(MutatePriority.UserInput) { + vertical.scroll(MutatePriority.UserInput) { awaitCancellation() } + } + } + } else if (pressed.size > 2) blocked = true + } + if (navigation.ownsTouches) { + if (pressed.size != 2 || pressed.map { it.id } != pairIds) { + val originalPair = event.changes.filter { it.id in pairIds.orEmpty() } + val normalRelease = pressed.size < 2 && originalPair.any { it.previousPressed && !it.pressed } + if (gesture?.mode != TwoFingerGesture.Mode.Ended && normalRelease) { + fling.release(event.changes.maxOf { it.uptimeMillis }, gesture?.mode == TwoFingerGesture.Mode.Pan) + } else if (pressed.any { it.id !in pairIds.orEmpty() } || pressed.size > 2 || + pressed.any { it.previousPressed && it.position != it.previousPosition }) { + fling.cancel() + } + gesture?.end() // No pointer handoff or one-finger tail after a pinch/pan. + } else { + val center = (pressed[0].position + pressed[1].position) / 2f + val distance = (pressed[0].position - pressed[1].position).getDistance() + val motion = gesture!!.update(center.x, center.y, distance) + fling.add(pressed.maxOf { it.uptimeMillis }, center) + if (gesture!!.mode == TwoFingerGesture.Mode.Pan) { + // ScrollState clamps and retains fractional px. No per-event coroutine queue. + horizontal.dispatchRawDelta(-motion.dx) + vertical.dispatchRawDelta(-motion.dy) + } else if (gesture!!.mode == TwoFingerGesture.Mode.Zoom && motion.zoom != 1f) { + navigation.zoomAtRoot?.invoke(motion.zoom, origin[0] + center) + } + } + // Initial pass wins over nested tables, buttons, headers and graph tap handlers. + event.changes.forEach { it.consume() } + } + val finalEvent = awaitPointerEvent(PointerEventPass.Final) + if (!navigation.ownsTouches && finalEvent.changes.any { + it.isConsumed && it.pressed && it.previousPressed && it.position != it.previousPosition + }) blocked = true // Preserve an already established single-finger drag. + if (pressed.isEmpty()) { + val velocity = fling.finish(event.changes.maxOf { it.uptimeMillis }) + if (velocity != Velocity.Zero) { + val lock = scrollLock + flingJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + lock?.cancelAndJoin() + flingWorkspace(horizontal, vertical, horizontalFling, verticalFling, velocity) + } + } + break + } + } + } finally { + scrollLock?.cancel() + navigation.reset() + } + } + } finally { + flingJob?.cancel() // Page change, project restore, disposal or pointer-input cancellation. + } + } +} + +/** Same axis-specific FlingBehavior used by the one-finger scroll modifiers, with no gain factor. */ +internal suspend fun flingWorkspace( + horizontal: ScrollState, vertical: ScrollState, + horizontalFling: FlingBehavior, verticalFling: FlingBehavior, velocity: Velocity +) = coroutineScope { + launch { horizontal.scroll { with(horizontalFling) { performFling(velocity.x) } } } + launch { vertical.scroll { with(verticalFling) { performFling(velocity.y) } } } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/project/NewProjectDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/project/NewProjectDialog.kt new file mode 100644 index 0000000..b8e4177 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/project/NewProjectDialog.kt @@ -0,0 +1,714 @@ +package com.dct.app.presentation.project + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.dct.app.domain.model.project.DpcProject + +// Figma Node 1:740 토큰 컬러 +private val ColorModalBg = Color(0xFFF3F4F6) +private val ColorModalBorder = Color(0xFF191C1E) +private val ColorOutline = Color(0xFFC3C6D6) +private val ColorPrimaryBlue = Color(0xFF003D9B) +private val ColorFooterBg = Color(0xFFEDEEF0) +private val ColorTextPrimary = Color(0xFF191C1E) +private val ColorTextSecondary = Color(0xFF535F73) +private val ColorButtonBg = Color(0xFFE1E2E4) + +/** + * Figma node 1:740 ("new project 클릭시") 100% 충실 이식 모달 다이얼로그 + */ +@Composable +fun NewProjectDialog( + dialogTitle: String = "New project", + project: DpcProject = DpcProject(), + onProjectChange: (DpcProject) -> Unit = {}, + onDismiss: () -> Unit, + onConfirm: (DpcProject) -> Unit, + onPickProjectFile: () -> Unit = {}, + onPickMdbFile: () -> Unit = {}, + onPickA2lFile: () -> Unit = {}, + onPickDbcFile: () -> Unit = {}, + onPickFolder: () -> Unit = {} +) { + var showSmSubDialog by remember { mutableStateOf(false) } + var showDidSubDialog by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + // 반투명 오버레이 백드롭 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)) + // The full-screen dismiss target must not draw a hover/ripple over its children. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ), + contentAlignment = Alignment.Center + ) { + // 모달 본체 (874dp x 592dp 규격) + Surface( + color = ColorModalBg, + shape = RoundedCornerShape(8.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorModalBorder), + shadowElevation = 8.dp, + modifier = Modifier + .widthIn(max = 874.dp) + .fillMaxWidth(0.92f) + .heightIn(max = 620.dp) + .clickable(enabled = false) {} // 클릭 이벤트 전파 차단 + ) { + Column(modifier = Modifier.fillMaxSize()) { + // 1. 헤더 (높이 48px, 타이틀 + 우측 X 닫기 버튼) + Surface( + color = Color.White, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .border(width = 1.dp, color = ColorOutline) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Text( + text = dialogTitle, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = ColorTextPrimary + ) + IconButton( + onClick = onDismiss, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = ColorTextSecondary, + modifier = Modifier.size(18.dp) + ) + } + } + } + + // 2. 폼 본문 영역 (스크롤 가능) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + val isA2lSelected = project.a2lFileName.isNotEmpty() + val isMdbSelected = project.mdbFileName.isNotEmpty() + + // (1) Vehicle code (영문 알파벳 및 숫자만 허용 - PC IsCharAlphaNumericA 규격) + DialogFormRow(label = "Vehicle code :") { + CustomDialogInputField( + value = project.vehicleCode, + onValueChange = { input -> + onProjectChange(project.copy(vehicleCode = input.filter { it.isLetterOrDigit() })) + }, + placeholder = "", + modifier = Modifier.width(220.dp) + ) + } + + // (2) Project file name (.dpc) + DialogFormRow(label = "Project file name :") { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + CustomDialogInputField( + value = project.projectFileName, + onValueChange = { input -> + onProjectChange(project.copy(projectFileName = input)) + }, + placeholder = "", + modifier = Modifier.weight(1f) + ) + Button( + onClick = onPickProjectFile, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = ColorButtonBg, + contentColor = ColorTextPrimary + ), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorOutline), + contentPadding = PaddingValues(0.dp), + modifier = Modifier + .width(44.dp) + .height(36.dp) + ) { + Icon( + Icons.Default.MoreHoriz, + contentDescription = "Browse", + tint = ColorTextPrimary, + modifier = Modifier.size(18.dp) + ) + } + } + } + + // (3) MDB file (.mdb) - A2L 선택 시 비활성화 (PC CAddNewProjectDlg::CheckButtonState 규격) + DialogFormRow(label = "MDB file :") { + DialogPathField( + path = project.mdbFileName, + placeholder = "", + enabled = !isA2lSelected, + onPick = onPickMdbFile + ) + } + + // (4) A2L file (.a2l) - MDB 선택 시 비활성화 (PC CAddNewProjectDlg::CheckButtonState 규격) + DialogFormRow(label = "A2L file :") { + DialogPathField( + path = project.a2lFileName, + placeholder = "", + enabled = !isMdbSelected, + onPick = onPickA2lFile + ) + } + + // (5) CAN DBC file (.dbc) + DialogFormRow(label = "CAN DBC file :") { + DialogPathField( + path = project.canDbcFileName, + placeholder = "", + onPick = onPickDbcFile + ) + } + + // (6) Folder for measured files + DialogFormRow(label = "Folder for measured files :") { + val defaultMeasured = remember { com.dct.app.data.repository.DpcProjectManager.getDefaultMeasuredFolder() } + val displayFolder = project.measuredFilesFolder.ifEmpty { defaultMeasured } + DialogPathField( + path = displayFolder, + placeholder = "", + onPick = onPickFolder + ) + } + + // (7) Write CSV, Write MF4 체크박스 + DialogFormRow(label = "") { + Row( + horizontalArrangement = Arrangement.spacedBy(28.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onProjectChange(project.copy(writeCsv = !project.writeCsv)) } + ) { + Checkbox( + checked = project.writeCsv, + onCheckedChange = { onProjectChange(project.copy(writeCsv = it)) }, + colors = CheckboxDefaults.colors(checkedColor = ColorPrimaryBlue) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Write CSV", fontSize = 13.sp, color = ColorTextPrimary) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onProjectChange(project.copy(writeMf4 = !project.writeMf4)) } + ) { + Checkbox( + checked = project.writeMf4, + onCheckedChange = { onProjectChange(project.copy(writeMf4 = it)) }, + colors = CheckboxDefaults.colors(checkedColor = ColorPrimaryBlue) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Write MF4", fontSize = 13.sp, color = ColorTextPrimary) + } + } + } + + HorizontalDivider(color = ColorOutline.copy(alpha = 0.5f), thickness = 1.dp) + + // (8) Sensor Module + DialogFormRow(label = "Sensor Module :") { + OutlinedButton( + onClick = { showSmSubDialog = true }, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = ColorTextPrimary + ), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorOutline), + modifier = Modifier.height(36.dp) + ) { + Text("Configure SM", fontSize = 13.sp, fontWeight = FontWeight.Medium) + } + } + + // (9) CAN mode (HiSpeed / FD / For old model) + DialogFormRow(label = "CAN mode :") { + Row( + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onProjectChange(project.copy(canMode = "CAN(HiSpeed)")) } + ) { + RadioButton( + selected = project.canMode == "CAN(HiSpeed)", + onClick = { onProjectChange(project.copy(canMode = "CAN(HiSpeed)")) }, + colors = RadioButtonDefaults.colors(selectedColor = ColorPrimaryBlue) + ) + Text("CAN(HiSpeed)", fontSize = 13.sp, color = ColorTextPrimary) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onProjectChange(project.copy(canMode = "CAN(FD)")) } + ) { + RadioButton( + selected = project.canMode == "CAN(FD)", + onClick = { onProjectChange(project.copy(canMode = "CAN(FD)")) }, + colors = RadioButtonDefaults.colors(selectedColor = ColorPrimaryBlue) + ) + Text("CAN(FD)", fontSize = 13.sp, color = ColorTextPrimary) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onProjectChange(project.copy(forOldModel = !project.forOldModel)) } + ) { + Checkbox( + checked = project.forOldModel, + onCheckedChange = { onProjectChange(project.copy(forOldModel = it)) }, + colors = CheckboxDefaults.colors(checkedColor = ColorPrimaryBlue) + ) + Text("For old model", fontSize = 13.sp, color = ColorTextPrimary) + } + } + } + + // (10) DID Information + DialogFormRow(label = "DID Information :") { + OutlinedButton( + onClick = { showDidSubDialog = true }, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = ColorTextPrimary + ), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorOutline), + modifier = Modifier.height(36.dp) + ) { + Text("Configure DID", fontSize = 13.sp, fontWeight = FontWeight.Medium) + } + } + } + + // 3. 푸터 액션바 (높이 45px, 배경 #EDEEF0, Cancel + OK) + Surface( + color = ColorFooterBg, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .border(width = 1.dp, color = ColorOutline) + ) { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp) + ) { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = ColorTextPrimary + ), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorOutline), + modifier = Modifier + .width(84.dp) + .height(32.dp), + contentPadding = PaddingValues(0.dp) + ) { + Text("Cancel", fontSize = 13.sp) + } + + Spacer(modifier = Modifier.width(12.dp)) + + val hasDefinitionFile = (project.mdbFileUri != null || project.mdbFileName.isNotBlank()) || + (project.a2lFileUri != null || project.a2lFileName.isNotBlank()) || + (project.canDbcFileUri != null || project.canDbcFileName.isNotBlank()) + val hasProjectName = project.projectFileUri != null || project.projectFileName.isNotBlank() + val isOkEnabled = project.vehicleCode.isNotBlank() && hasProjectName && hasDefinitionFile + + Button( + onClick = { + if (isOkEnabled) { + onConfirm(project) + } + }, + enabled = isOkEnabled, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = ColorPrimaryBlue, + disabledContainerColor = ColorButtonBg + ), + modifier = Modifier + .width(84.dp) + .height(32.dp), + contentPadding = PaddingValues(0.dp) + ) { + Text( + "OK", + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = if (isOkEnabled) Color.White else ColorTextSecondary.copy(alpha = 0.6f) + ) + } + } + } + } + } + } + } + + if (showSmSubDialog) { + SensorModuleConfigDialog( + initialModules = project.sensorModules, + onDismiss = { showSmSubDialog = false }, + onConfirm = { updatedModules -> + onProjectChange(project.copy(sensorModules = updatedModules, smConfigured = true)) + showSmSubDialog = false + } + ) + } + + if (showDidSubDialog) { + DidInformationDialog( + initialDidInfo = project.didInfo, + onDismiss = { showDidSubDialog = false }, + onConfirm = { updatedDidInfo -> + onProjectChange(project.copy(didInfo = updatedDidInfo, didConfigured = true)) + showDidSubDialog = false + } + ) + } +} + + + +/** + * ECU 진단 식별자(DID) 구성 다이얼로그 (PC CDIDInformationDlg 규격) + */ +@Composable +fun DidInformationDialog( + initialDidInfo: com.dct.app.domain.model.project.DpcDidInfo, + onDismiss: () -> Unit, + onConfirm: (com.dct.app.domain.model.project.DpcDidInfo) -> Unit +) { + var didInfo by remember { mutableStateOf(initialDidInfo) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.45f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ), + contentAlignment = Alignment.Center + ) { + Surface( + color = ColorModalBg, + shape = RoundedCornerShape(8.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorModalBorder), + shadowElevation = 10.dp, + modifier = Modifier + .widthIn(max = 620.dp) + .fillMaxWidth(0.85f) + .heightIn(max = 540.dp) + .clickable(enabled = false) {} + ) { + Column(modifier = Modifier.fillMaxSize()) { + // 헤더 + Surface( + color = Color.White, + modifier = Modifier.fillMaxWidth().height(48.dp).border(width = 1.dp, color = ColorOutline) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp) + ) { + Text( + text = "DID Information Configuration", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = ColorTextPrimary + ) + IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Close, contentDescription = "Close", tint = ColorTextSecondary, modifier = Modifier.size(18.dp)) + } + } + } + + // 본문 폼 + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + DialogFormRow(label = "Part Name 2 (F100) :") { + CustomDialogInputField( + value = didInfo.partName2, + onValueChange = { didInfo = didInfo.copy(partName2 = it.take(25)) }, + placeholder = "최대 25자" + ) + } + + DialogFormRow(label = "ECU HW Version (F191) :") { + CustomDialogInputField( + value = didInfo.ecuHwVersion, + onValueChange = { didInfo = didInfo.copy(ecuHwVersion = it.take(4)) }, + placeholder = "최대 4자" + ) + } + + DialogFormRow(label = "Part Number (F187) :") { + CustomDialogInputField( + value = didInfo.partNumber, + onValueChange = { didInfo = didInfo.copy(partNumber = it.take(10)) }, + placeholder = "최대 10자" + ) + } + + DialogFormRow(label = "Product Date (F18B) :") { + CustomDialogInputField( + value = didInfo.productDate, + onValueChange = { didInfo = didInfo.copy(productDate = it.take(8)) }, + placeholder = "예: 20260825 (최대 8자)" + ) + } + + DialogFormRow(label = "Supplier Code (F1A1) :") { + CustomDialogInputField( + value = didInfo.supplierCode, + onValueChange = { didInfo = didInfo.copy(supplierCode = it.take(4)) }, + placeholder = "최대 4자" + ) + } + + DialogFormRow(label = "System Name (F197) :") { + CustomDialogInputField( + value = didInfo.systemName, + onValueChange = { didInfo = didInfo.copy(systemName = it.take(4)) }, + placeholder = "최대 4자" + ) + } + + DialogFormRow(label = "Supplier HW (F193) :") { + CustomDialogInputField( + value = didInfo.supplierHwVersion, + onValueChange = { didInfo = didInfo.copy(supplierHwVersion = it.take(4)) }, + placeholder = "최대 4자" + ) + } + } + + // 푸터 액션바 + Surface( + color = ColorFooterBg, + modifier = Modifier.fillMaxWidth().height(48.dp).border(1.dp, ColorOutline) + ) { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp) + ) { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.outlinedButtonColors(containerColor = Color.White, contentColor = ColorTextPrimary), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorOutline), + modifier = Modifier.width(80.dp).height(32.dp), + contentPadding = PaddingValues(0.dp) + ) { + Text("Cancel", fontSize = 13.sp) + } + Spacer(modifier = Modifier.width(10.dp)) + Button( + onClick = { onConfirm(didInfo) }, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = ColorPrimaryBlue), + modifier = Modifier.width(80.dp).height(32.dp), + contentPadding = PaddingValues(0.dp) + ) { + Text("OK", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White) + } + } + } + } + } + } + } +} + +@Composable +private fun DialogFormRow( + label: String, + content: @Composable () -> Unit +) { + val configuration = androidx.compose.ui.platform.LocalConfiguration.current + val isPortrait = configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT + val labelWidth = if (isPortrait) 130.dp else 180.dp + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + if (label.isNotEmpty()) { + Text( + text = label, + fontSize = if (isPortrait) 12.sp else 13.sp, + fontWeight = FontWeight.Medium, + color = ColorTextPrimary, + modifier = Modifier.width(labelWidth) + ) + } else { + Spacer(modifier = Modifier.width(labelWidth)) + } + Box(modifier = Modifier.weight(1f)) { + content() + } + } +} + +@Composable +private fun CustomDialogInputField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String = "", + readOnly: Boolean = false, + enabled: Boolean = true, + modifier: Modifier = Modifier +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + readOnly = readOnly, + enabled = enabled, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 13.sp, + color = if (enabled) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.5f), + fontWeight = FontWeight.Medium + ), + decorationBox = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .background(if (enabled) Color.White else Color(0xFFE9ECEF), RoundedCornerShape(4.dp)) + .border(1.dp, if (enabled) ColorOutline else ColorOutline.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + if (value.isEmpty() && placeholder.isNotEmpty()) { + Text( + text = placeholder, + fontSize = 13.sp, + color = ColorTextSecondary.copy(alpha = 0.7f) + ) + } else { + innerTextField() + } + } + }, + modifier = modifier.height(36.dp) + ) +} + +@Composable +private fun DialogPathField( + path: String, + placeholder: String, + enabled: Boolean = true, + onPick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + CustomDialogInputField( + value = path, + onValueChange = {}, + readOnly = true, + enabled = enabled, + placeholder = placeholder, + modifier = Modifier.weight(1f) + ) + + Button( + onClick = onPick, + enabled = enabled, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = ColorButtonBg, + contentColor = ColorTextPrimary, + disabledContainerColor = Color(0xFFE9ECEF), + disabledContentColor = ColorTextSecondary.copy(alpha = 0.4f) + ), + border = androidx.compose.foundation.BorderStroke(1.dp, if (enabled) ColorOutline else ColorOutline.copy(alpha = 0.5f)), + contentPadding = PaddingValues(0.dp), + modifier = Modifier + .width(44.dp) + .height(36.dp) + ) { + Icon( + Icons.Default.MoreHoriz, + contentDescription = "Browse", + tint = if (enabled) ColorTextPrimary else ColorTextSecondary.copy(alpha = 0.4f), + modifier = Modifier.size(18.dp) + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/project/SensorModuleConfigDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/project/SensorModuleConfigDialog.kt new file mode 100644 index 0000000..ba3b8ee --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/project/SensorModuleConfigDialog.kt @@ -0,0 +1,345 @@ +package com.dct.app.presentation.project + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.dct.app.domain.model.project.DpcSensorModuleConfig +import com.dct.app.domain.model.project.createDefaultSensorModules + +private val ColorPrimaryBlue = Color(0xFF1E88E5) +private val ColorTextPrimary = Color(0xFF212529) +private val ColorTextSecondary = Color(0xFF6C757D) +private val ColorOutline = Color(0xFFCED4DA) +private val ColorModalBg = Color(0xFFF8F9FA) +private val ColorModalBorder = Color(0xFFADB5BD) +private val ColorFooterBg = Color(0xFFF1F3F5) + +/** + * 센서 모듈(Sensor Module, SM 1~4) 4개 탭 및 16채널 구성 다이얼로그 (PC CSMConfigDlg 규격) + */ +@Composable +fun SensorModuleConfigDialog( + initialModules: List, + onDismiss: () -> Unit, + onConfirm: (List) -> Unit +) { + var modules by remember { + mutableStateOf( + if (initialModules.isNotEmpty()) initialModules else createDefaultSensorModules() + ) + } + var selectedTab by remember { mutableStateOf(0) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.45f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ), + contentAlignment = Alignment.Center + ) { + Surface( + color = ColorModalBg, + shape = RoundedCornerShape(8.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorModalBorder), + shadowElevation = 10.dp, + modifier = Modifier + .widthIn(max = 760.dp) + .fillMaxWidth(0.85f) + .heightIn(max = 580.dp) + .clickable(enabled = false) {} + ) { + Column(modifier = Modifier.fillMaxSize()) { + // 헤더 + Surface( + color = Color.White, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .border(width = 1.dp, color = ColorOutline) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Text( + text = "Sensor Module Configuration (SM 1~4)", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = ColorTextPrimary + ) + IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Close, contentDescription = "Close", tint = ColorTextSecondary, modifier = Modifier.size(18.dp)) + } + } + } + + // 탭 바 (SM 1, SM 2, SM 3, SM 4) + TabRow( + selectedTabIndex = selectedTab, + containerColor = Color(0xFFE9ECEF), + contentColor = ColorPrimaryBlue, + modifier = Modifier.fillMaxWidth().height(42.dp) + ) { + (0..3).forEach { index -> + val isModUsed = modules.getOrNull(index)?.isUsed == true + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "SM ${index + 1}", + fontSize = 13.sp, + fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal + ) + if (isModUsed) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .size(6.dp) + .background(Color(0xFF009A4C), androidx.compose.foundation.shape.CircleShape) + ) + } + } + } + ) + } + } + + // 탭 본문 영역 + val currentMod = modules.getOrElse(selectedTab) { + DpcSensorModuleConfig(selectedTab) + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(16.dp) + ) { + // Use Sensor Module 체크박스 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + .clickable { + val updated = modules.toMutableList() + updated[selectedTab] = currentMod.copy(isUsed = !currentMod.isUsed) + modules = updated + } + ) { + Checkbox( + checked = currentMod.isUsed, + onCheckedChange = { isChecked -> + val updated = modules.toMutableList() + updated[selectedTab] = currentMod.copy(isUsed = isChecked) + modules = updated + }, + colors = CheckboxDefaults.colors(checkedColor = ColorPrimaryBlue) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Use Sensor Module ${selectedTab + 1}", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = ColorTextPrimary + ) + } + + // 채널 테이블 (16채널) + Surface( + shape = RoundedCornerShape(4.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorOutline), + color = Color.White, + modifier = Modifier.weight(1f).fillMaxWidth() + ) { + Column(modifier = Modifier.fillMaxSize()) { + // 테이블 헤더 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFE9ECEF)) + .padding(horizontal = 12.dp, vertical = 6.dp) + .border(0.5.dp, ColorOutline) + ) { + Text("Channel", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = ColorTextPrimary, modifier = Modifier.width(90.dp)) + Text("Type (Click to toggle)", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = ColorTextPrimary, modifier = Modifier.width(160.dp)) + Text("Unit", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = ColorTextPrimary, modifier = Modifier.width(50.dp)) + Text("Description", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = ColorTextPrimary, modifier = Modifier.weight(1f)) + } + + // 채널 리스트 (16개) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + currentMod.channels.forEachIndexed { chIdx, ch -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(if (chIdx % 2 == 1) Color(0xFFF8F9FA) else Color.White) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text(text = ch.name, fontSize = 12.sp, fontWeight = FontWeight.Medium, color = ColorTextPrimary, modifier = Modifier.width(90.dp), style = androidx.compose.ui.text.TextStyle(platformStyle = androidx.compose.ui.text.PlatformTextStyle(includeFontPadding = false))) + + // 타입 토글 버튼 (Temperature 'C <-> Voltage V) + Surface( + shape = RoundedCornerShape(4.dp), + color = if (ch.isVoltage) Color(0xFFFFF3CD) else Color(0xFFD1E7DD), + border = androidx.compose.foundation.BorderStroke(1.dp, if (ch.isVoltage) Color(0xFFFFC107) else Color(0xFF198754)), + modifier = Modifier + .width(150.dp) + .height(28.dp) + .clickable { + val updatedChList = currentMod.channels.toMutableList() + val newVolt = !ch.isVoltage + updatedChList[chIdx] = ch.copy( + isVoltage = newVolt, + unit = if (newVolt) "V" else "'C" + ) + val updatedMods = modules.toMutableList() + updatedMods[selectedTab] = currentMod.copy(channels = updatedChList) + modules = updatedMods + } + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text( + text = if (ch.isVoltage) "Voltage (V)" else "Temperature ('C)", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = if (ch.isVoltage) Color(0xFF664D03) else Color(0xFF0F5132), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } + + Spacer(modifier = Modifier.width(10.dp)) + + Text(text = ch.unit, fontSize = 12.sp, fontWeight = FontWeight.Medium, color = ColorTextSecondary, modifier = Modifier.width(50.dp), style = androidx.compose.ui.text.TextStyle(platformStyle = androidx.compose.ui.text.PlatformTextStyle(includeFontPadding = false))) + + // 설명 인라인 편집 필드 + BasicTextField( + value = ch.description, + onValueChange = { newDesc -> + val updatedChList = currentMod.channels.toMutableList() + updatedChList[chIdx] = ch.copy(description = newDesc) + val updatedMods = modules.toMutableList() + updatedMods[selectedTab] = currentMod.copy(channels = updatedChList) + modules = updatedMods + }, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 12.sp, + color = ColorTextPrimary, + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ), + decorationBox = { inner -> + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(2.dp)) + .border(0.5.dp, ColorOutline.copy(alpha = 0.6f), RoundedCornerShape(2.dp)) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.CenterStart + ) { + if (ch.description.isEmpty()) { + Text( + text = "설명 입력...", + fontSize = 11.sp, + color = ColorTextSecondary.copy(alpha = 0.5f), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + inner() + } + }, + modifier = Modifier.weight(1f).height(28.dp) + ) + } + HorizontalDivider(color = ColorOutline.copy(alpha = 0.3f), thickness = 0.5.dp) + } + } + } + } + } + + // 푸터 액션바 + Surface( + color = ColorFooterBg, + modifier = Modifier.fillMaxWidth().height(48.dp).border(1.dp, ColorOutline) + ) { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp) + ) { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.outlinedButtonColors(containerColor = Color.White, contentColor = ColorTextPrimary), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorOutline), + modifier = Modifier.width(80.dp).height(32.dp), + contentPadding = PaddingValues(0.dp) + ) { + Text("Cancel", fontSize = 13.sp) + } + Spacer(modifier = Modifier.width(10.dp)) + Button( + onClick = { onConfirm(modules) }, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = ColorPrimaryBlue), + modifier = Modifier.width(80.dp).height(32.dp), + contentPadding = PaddingValues(0.dp) + ) { + Text("OK", fontSize = 13.sp, color = Color.White) + } + } + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/A2lSignalTableView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/A2lSignalTableView.kt new file mode 100644 index 0000000..7fa16e7 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/A2lSignalTableView.kt @@ -0,0 +1,906 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.data.parser.A2lParser.A2lCharacteristic +import com.dct.app.data.parser.A2lParser.A2lCharacteristicArray +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.SignalDragDropManager +import java.util.Locale + +/** + * A2L Signal 전용 데이터 테이블 창 (PC CA2LMeasurementDataFormView 100% 호환) + * 기본: Name(100) | Value(60) | Unit(40) | Color(55) | Description(150) + * Expand 시: Min(55) | Max(55) | Address(50) | Data type(50) | Value (Hex)(60) + */ +@Composable +fun WindowsClassicA2lSignalTableWindow( + a2lSignals: List, + liveTelemetryValues: Map, + onSignalClick: (SignalDefinition) -> Unit = {}, + onSignalDrop: ((SignalDefinition) -> Unit)? = null, + onColorChange: (SignalDefinition, Long) -> Unit = { _, _ -> }, + onDescriptionChange: (SignalDefinition, String) -> Unit = { _, _ -> }, + modifier: Modifier = Modifier +) { + var searchQuery by rememberWindowString("search") + var isExpanded by rememberWindowBoolean("expanded") + var selectedSignalKey by rememberWindowNullableString("selected_signal") + + val filteredSignals = remember(a2lSignals, searchQuery) { + if (searchQuery.isBlank()) a2lSignals else a2lSignals.filter { it.name.contains(searchQuery, true) } + } + + val customWidths = rememberWindowColumnWidths() + + val widthName = customWidths["name"] ?: 110.dp + val widthValue = customWidths["value"] ?: 65.dp + val widthUnit = customWidths["unit"] ?: 45.dp + val widthColor = customWidths["color"] ?: 55.dp + val widthDesc = customWidths["desc"] ?: 160.dp + val widthMin = customWidths["min"] ?: 55.dp + val widthMax = customWidths["max"] ?: 55.dp + val widthAddr = customWidths["addr"] ?: 75.dp + val widthDataType = customWidths["dataType"] ?: 60.dp + val widthHex = customWidths["hex"] ?: 75.dp + + fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { + val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) + if (customWidths.containsKey(key)) { + customWidths.remove(key) + } else { + customWidths[key] = autoW + } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White) + ) { + TableSubHeader( + searchQuery = searchQuery, + onSearchChange = { searchQuery = it }, + isExpanded = isExpanded, + onExpandClick = { isExpanded = !isExpanded } + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + val totalWidth = widthName + widthValue + widthUnit + widthColor + widthDesc + + if (isExpanded) (widthMin + widthMax + widthAddr + widthDataType + widthHex + 10.dp) else 5.dp + + Column(modifier = Modifier.width(totalWidth)) { + // Table Header + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(Color(0xFFE9ECEF)) + .border(1.dp, Color(0xFFCED4DA)), + verticalAlignment = Alignment.CenterVertically + ) { + ResizableTableHeaderCell("Name", widthName, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 110.dp, filteredSignals.map { it.name }) }) + ResizableTableHeaderCell("Value", widthValue, onWidthChange = { customWidths["value"] = it }, onDoubleTap = { toggleAutoFit("value", 65.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["a2l_${sig.adl}"] ?: liveTelemetryValues[sig.name]; if (v != null) String.format(Locale.US, "%.3f", v) else "0.0" }) }) + ResizableTableHeaderCell("Unit", widthUnit, onWidthChange = { customWidths["unit"] = it }, onDoubleTap = { toggleAutoFit("unit", 45.dp, filteredSignals.map { it.unit }) }) + ResizableTableHeaderCell("Color", widthColor, onWidthChange = { customWidths["color"] = it }, onDoubleTap = { toggleAutoFit("color", 55.dp, emptyList()) }) + ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 160.dp, filteredSignals.map { it.description }) }, showDivider = isExpanded) + if (isExpanded) { + ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.min) }) }) + ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.max) }) }) + ResizableTableHeaderCell("Address", widthAddr, onWidthChange = { customWidths["addr"] = it }, onDoubleTap = { toggleAutoFit("addr", 75.dp, filteredSignals.map { String.format(Locale.US, "0x%08X", it.adl.toLong() and 0xFFFFFFFFL) }) }) + ResizableTableHeaderCell("Data type", widthDataType, onWidthChange = { customWidths["dataType"] = it }, onDoubleTap = { toggleAutoFit("dataType", 60.dp, filteredSignals.map { it.attr.name.replace("ATTR_", "") }) }) + ResizableTableHeaderCell("Value (Hex)", widthHex, onWidthChange = { customWidths["hex"] = it }, onDoubleTap = { toggleAutoFit("hex", 75.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["a2l_${sig.adl}"] ?: liveTelemetryValues[sig.name]; val raw = v?.let { sig.calculateRawValue(it).toLong() } ?: 0L; String.format(Locale.US, "0x%08X", raw and 0xFFFFFFFFL) }) }, showDivider = false) + } + } + + // Table Rows + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(filteredSignals, key = { _, s -> "a2l_${s.adl}_${s.name}" }) { index, sig -> + val sigKey = "a2l_${sig.adl}" + val isSelected = (sigKey == selectedSignalKey) + val liveVal = liveTelemetryValues[sigKey] ?: liveTelemetryValues[sig.name] + val displayVal = if (liveVal != null) String.format(Locale.US, "%.3f", liveVal) else "0.0" + val rawVal = liveVal?.let { sig.calculateRawValue(it).toLong() } ?: 0L + val hexVal = String.format(Locale.US, "0x%08X", rawVal and 0xFFFFFFFFL) + + var rowPosInRoot by remember { mutableStateOf(Offset.Zero) } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(if (isSelected) Color(0xFFCCE8FF) else if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) + .border(0.5.dp, Color(0xFFE5E5E5)) + .onGloballyPositioned { coordinates -> + rowPosInRoot = coordinates.positionInRoot() + } + .pointerInput(sigKey) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + SignalDragDropManager.startDrag( + signal = DraggedSignal( + signalId = sigKey, + signalName = sig.name, + colorLong = sig.color, + unit = sig.unit, + isCan = false, + isA2l = true + ), + startPos = rowPosInRoot + localOffset + ) + }, + onDrag = { change, dragAmount -> + change.consume() + SignalDragDropManager.updateDrag(dragAmount) + }, + onDragEnd = { + SignalDragDropManager.endDrag { + onSignalDrop?.invoke(sig) + } + }, + onDragCancel = { + SignalDragDropManager.cancelDrag() + } + ) + } + .clickable { + selectedSignalKey = sigKey + onSignalClick(sig) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text(sig.name, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthName).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(displayVal, fontSize = 11.sp, color = if (liveVal != null) Color(0xFF212529) else Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthValue).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(sig.unit, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, modifier = Modifier.width(widthUnit).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + EditableTableColorCell( + colorValue = sig.color, + onColorChanged = { newColor -> onColorChange(sig, newColor) }, + modifier = Modifier.width(widthColor) + ) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + TableEditableDescriptionCell( + description = sig.description, + onDescriptionChanged = { newDesc -> onDescriptionChange(sig, newDesc) }, + modifier = Modifier.width(widthDesc) + ) + if (isExpanded) { + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "%.3f", sig.min), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMin).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "%.3f", sig.max), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMax).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "0x%08X", sig.adl.toLong() and 0xFFFFFFFFL), fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthAddr).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(sig.attr.name.replace("ATTR_", ""), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthDataType).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(hexVal, fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthHex).padding(horizontal = 4.dp)) + } + } + } + } + } + } + } +} + +/** + * 6. A2L Characteristics (단일 특성치/파라미터) 전용 데이터 테이블 창 (PC CA2LCharacteristicFormView 1:1 일치) + * - 컬럼: Name (체크박스) | Value (수정 가능) | Description | Min | Max | Address + * - 기능: [Expand / Collapse] 토글 버튼 (기본 3개 컬럼 축소 <-> 6개 전체 컬럼 확장) + * - 기능: [✓ Show Checked] 선택된 항목만 필터링 보기 + * - 정렬: Address 오름차순 (PC 원본 동일) + */ +@Composable +fun WindowsClassicA2lCharacteristicTableWindow( + characteristics: List, + onValueChange: (A2lCharacteristic, Double) -> Unit = { _, _ -> }, + onRead: ((A2lCharacteristic) -> Unit)? = null, + onDescriptionChange: (A2lCharacteristic, String) -> Unit = { _, _ -> }, + modifier: Modifier = Modifier +) { + var searchQuery by rememberWindowString("search") + var isExpanded by rememberWindowBoolean("expanded") + var showOnlyChecked by rememberWindowBoolean("show_checked") + var checkedAddresses by rememberWindowChecked("checked_addresses") + + var editingChar by remember { mutableStateOf(null) } + var editValueText by remember { mutableStateOf("") } + var editErrorText by remember { mutableStateOf(null) } + + // PC 규격: Address 오름차순 정렬 및 필터링 + val filtered = remember(characteristics, searchQuery, showOnlyChecked, checkedAddresses) { + var list = characteristics.sortedBy { it.ecuAddress } + if (showOnlyChecked) { + list = list.filter { it.ecuAddress in checkedAddresses } + } + if (searchQuery.isNotBlank()) { + list = list.filter { it.name.contains(searchQuery, true) } + } + list + } + + val customWidths = rememberWindowColumnWidths() + + val widthName = customWidths["name"] ?: 130.dp + val widthValue = customWidths["value"] ?: 70.dp + val widthDesc = customWidths["desc"] ?: 140.dp + val widthMin = customWidths["min"] ?: 55.dp + val widthMax = customWidths["max"] ?: 55.dp + val widthAddr = customWidths["addr"] ?: 75.dp + + fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { + val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) + if (customWidths.containsKey(key)) { + customWidths.remove(key) + } else { + customWidths[key] = autoW + } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(Color(0xFFF0F0F0)) + .padding(6.dp) + ) { + // 상단 제어 바: 검색창 + [✓] Show Checked 체크박스 + (우측 끝) [ Expand / Collapse ] 버튼 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 검색창 (좌측) + Box( + modifier = Modifier + .width(130.dp) + .height(26.dp) + .background(Color.White) + .border(1.dp, Color(0xFF7F9DB9)) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 11.sp, + color = Color.Black, + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (searchQuery.isEmpty()) { + Text( + text = "Search...", + fontSize = 11.sp, + color = Color.Gray, + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Show Checked Items 체크박스 (PC IDC_CHECK_SHOW_CHECKEDITEM) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { showOnlyChecked = !showOnlyChecked } + ) { + Checkbox( + checked = showOnlyChecked, + onCheckedChange = { showOnlyChecked = it }, + modifier = Modifier.size(20.dp), + colors = CheckboxDefaults.colors( + checkedColor = Color(0xFF0052CC), + uncheckedColor = Color(0xFF707070) + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Show Checked", fontSize = 11.sp, color = Color.Black) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Expand / Collapse 버튼 (오른쪽 끝 통일) + Surface( + color = Color(0xFFF8F9FA), + shape = RoundedCornerShape(2.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFB0B8C0)), + modifier = Modifier + .height(26.dp) + .clickable { isExpanded = !isExpanded } + ) { + Box( + modifier = Modifier.padding(horizontal = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (isExpanded) "Collapse" else "Expand", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF202124), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } + } + + // 테이블 영역 + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color.White) + .border(1.dp, Color(0xFF7F9DB9)) + .horizontalScroll(rememberScrollState()) + ) { + val totalTableWidth = widthName + widthValue + widthDesc + + if (isExpanded) (widthMin + widthMax + widthAddr + 10.dp) else 5.dp + + Column(modifier = Modifier.width(totalTableWidth)) { + // Table Header (PC CA2LCharacteristicFormView 컬럼 규격) + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(Color(0xFFE2E2E2)) + .border(1.dp, Color(0xFFC0C0C0)), + verticalAlignment = Alignment.CenterVertically + ) { + ResizableTableHeaderCell("Name", widthName, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 130.dp, filtered.map { it.name }, extraPadding = 36.dp) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Value", widthValue, onWidthChange = { customWidths["value"] = it }, onDoubleTap = { toggleAutoFit("value", 70.dp, filtered.map { if (it.value == it.value.toLong().toDouble()) String.format(Locale.US, "%.0f", it.value) else String.format(Locale.US, "%.3f", it.value) }) }, textColor = Color(0xFF0052CC), dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 140.dp, filtered.map { it.description }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), showDivider = isExpanded) + if (isExpanded) { + ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filtered.map { if (it.lowerLimit == it.lowerLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", it.lowerLimit) else String.format(Locale.US, "%.3f", it.lowerLimit) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filtered.map { if (it.upperLimit == it.upperLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", it.upperLimit) else String.format(Locale.US, "%.3f", it.upperLimit) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Address", widthAddr, onWidthChange = { customWidths["addr"] = it }, onDoubleTap = { toggleAutoFit("addr", 75.dp, filtered.map { String.format(Locale.US, "0x%08X", it.ecuAddress) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), showDivider = false) + } + } + + // Table Rows + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(filtered, key = { _, char -> "a2l_char_${char.ecuAddress}_${char.name}" }) { index, char -> + val isChecked = char.ecuAddress in checkedAddresses + + Row( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .background(if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) + .border(0.5.dp, Color(0xFFE5E5E5)) + .clickable { + editingChar = char + editValueText = if (char.value == char.value.toLong().toDouble()) String.format(Locale.US, "%.0f", char.value) else String.format(Locale.US, "%.3f", char.value) + editErrorText = null + } + ) { + // Name + Checkbox + Row( + modifier = Modifier + .width(widthName) + .fillMaxHeight() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = isChecked, + onCheckedChange = { checked -> + if (checked) checkedAddresses = checkedAddresses + char.ecuAddress + else checkedAddresses = checkedAddresses - char.ecuAddress + }, + modifier = Modifier.size(16.dp), + colors = CheckboxDefaults.colors( + checkedColor = Color(0xFF0052CC), + uncheckedColor = Color(0xFF888888) + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = char.name, + fontSize = 11.sp, + color = Color.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) + } + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + + // Value + Box(modifier = Modifier.width(widthValue).fillMaxHeight().padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + val valStr = if (char.value == char.value.toLong().toDouble()) String.format(Locale.US, "%.0f", char.value) else String.format(Locale.US, "%.3f", char.value) + Text(valStr, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color(0xFF0052CC), maxLines = 1, softWrap = false) + } + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + + // Description + Box( + modifier = Modifier.width(widthDesc).fillMaxHeight().padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart + ) { + TableEditableDescriptionCell(char.description, { onDescriptionChange(char, it) }) + } + if (isExpanded) { + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Box(modifier = Modifier.width(widthMin).fillMaxHeight().padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + val minStr = if (char.lowerLimit == char.lowerLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", char.lowerLimit) else String.format(Locale.US, "%.3f", char.lowerLimit) + Text(minStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, softWrap = false) + } + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Box(modifier = Modifier.width(widthMax).fillMaxHeight().padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + val maxStr = if (char.upperLimit == char.upperLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", char.upperLimit) else String.format(Locale.US, "%.3f", char.upperLimit) + Text(maxStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, softWrap = false) + } + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Box(modifier = Modifier.width(widthAddr).fillMaxHeight().padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + Text(String.format(Locale.US, "0x%08X", char.ecuAddress), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = Color.Black, maxLines = 1, softWrap = false) + } + } + } + } + } + } + } + } + + // 값 수정 다이얼로그 (Min ~ Max 범위 검증 포함) + editingChar?.let { char -> + AlertDialog( + onDismissRequest = { editingChar = null }, + title = { + Text( + text = "Edit Characteristic - ${char.name}", + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Address: 0x${char.ecuAddress.toString(16).uppercase().padStart(8, '0')} | Range: [${char.lowerLimit} ~ ${char.upperLimit}]", + fontSize = 11.sp, + color = Color(0xFF6C757D), + modifier = Modifier.padding(bottom = 8.dp) + ) + OutlinedTextField( + value = editValueText, + onValueChange = { + editValueText = it + val parsed = it.toDoubleOrNull() + editErrorText = when { + parsed == null -> "유효한 숫자를 입력하십시오." + parsed < char.lowerLimit || parsed > char.upperLimit -> + "범위를 벗어났습니다. [${char.lowerLimit} ~ ${char.upperLimit}]" + else -> null + } + }, + isError = editErrorText != null, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (editErrorText != null) { + Text( + text = editErrorText!!, + color = MaterialTheme.colorScheme.error, + fontSize = 11.sp, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + }, + confirmButton = { + Button( + onClick = { + val parsed = editValueText.toDoubleOrNull() + if (parsed != null && parsed >= char.lowerLimit && parsed <= char.upperLimit) { + onValueChange(char, parsed) + editingChar = null + } + }, + enabled = editErrorText == null && editValueText.toDoubleOrNull() != null + ) { + Text("적용", fontSize = 12.sp) + } + }, + dismissButton = { + Row { + if (onRead != null) TextButton(onClick = { onRead(char); editingChar = null }) { Text("장비에서 읽기") } + TextButton(onClick = { editingChar = null }) { + Text("취소", fontSize = 12.sp) + } + } + } + ) + } +} + +/** + * 7. A2L CharacteristicsArray (배열 특성치) 전용 데이터 테이블 창 (PC CA2LCharacteristicArrayFormView 1:1 일치) + * - 상단: 전체 배열 목록 테이블 (Name, Description, Min, Max, Address) + * - 중간: Characteristics array name : 서브타이틀 바 + * - 하단: 2D 동적 매트릭스 그리드 (Array | 00 | 01 | 02 ... XSize-1) 및 셀 인라인 편집 + */ +@Composable +fun WindowsClassicA2lCharacteristicArrayTableWindow( + characteristicsArray: List, + onCellChange: (A2lCharacteristicArray, Int, Double) -> Unit = { _, _, _ -> }, + onReadCell: ((A2lCharacteristicArray, Int) -> Unit)? = null, + onDescriptionChange: (A2lCharacteristicArray, String) -> Unit = { _, _ -> }, + modifier: Modifier = Modifier +) { + var searchQuery by rememberWindowString("search") + var selectedIndex by rememberWindowInt("selected_index") + + val filtered = remember(characteristicsArray, searchQuery) { + var list = characteristicsArray.sortedBy { it.ecuAddress } + if (searchQuery.isNotBlank()) { + list = list.filter { it.name.contains(searchQuery, true) } + } + list + } + + val selectedArr = if (filtered.isNotEmpty()) { + filtered.getOrNull(selectedIndex) ?: filtered[0] + } else null + + // 셀 값 편집 다이얼로그 상태 + var editingCellIndex by remember { mutableStateOf(null) } + var editCellText by remember { mutableStateOf("") } + var editCellError by remember { mutableStateOf(null) } + + Column( + modifier = modifier + .fillMaxSize() + .background(Color(0xFFF0F0F0)) + .padding(6.dp) + ) { + // 1. 상단 검색창 + Box( + modifier = Modifier + .width(130.dp) + .height(24.dp) + .background(Color.White) + .border(1.dp, Color(0xFF7F9DB9)) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + selectedIndex = 0 + }, + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 11.sp, color = Color.Black), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (searchQuery.isEmpty()) { + Text("Search...", fontSize = 11.sp, color = Color.Gray) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + val customWidths = rememberWindowColumnWidths() + + val widthName = customWidths["name"] ?: 130.dp + val widthDesc = customWidths["desc"] ?: 140.dp + val widthMin = customWidths["min"] ?: 55.dp + val widthMax = customWidths["max"] ?: 55.dp + val widthAddr = customWidths["addr"] ?: 75.dp + + fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { + val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) + if (customWidths.containsKey(key)) { + customWidths.remove(key) + } else { + customWidths[key] = autoW + } + } + + // 2. 상단 배열 목록 테이블 (PC m_ChartListA 1:1 일치) + Box( + modifier = Modifier + .fillMaxWidth() + .height(130.dp) + .background(Color.White) + .border(1.dp, Color(0xFF7F9DB9)) + .horizontalScroll(rememberScrollState()) + ) { + val totalWidth = widthName + widthDesc + widthMin + widthMax + widthAddr + 10.dp + Column(modifier = Modifier.width(totalWidth)) { + // Header (Name | Description | Min | Max | Address) + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(Color(0xFFE2E2E2)) + .border(1.dp, Color(0xFFC0C0C0)), + verticalAlignment = Alignment.CenterVertically + ) { + ResizableTableHeaderCell("Name", widthName, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 130.dp, filtered.map { it.name }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 140.dp, filtered.map { it.description }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filtered.map { if (it.lowerLimit == it.lowerLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", it.lowerLimit) else String.format(Locale.US, "%.3f", it.lowerLimit) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filtered.map { if (it.upperLimit == it.upperLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", it.upperLimit) else String.format(Locale.US, "%.3f", it.upperLimit) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0)) + ResizableTableHeaderCell("Address", widthAddr, onWidthChange = { customWidths["addr"] = it }, onDoubleTap = { toggleAutoFit("addr", 75.dp, filtered.map { String.format("0x%08X", it.ecuAddress) }) }, textColor = Color.Black, dividerColor = Color(0xFFC0C0C0), showDivider = false) + } + + // Rows + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(filtered, key = { _, arr -> "a2l_arr_${arr.ecuAddress}_${arr.name}" }) { index, arr -> + val isSelected = (index == selectedIndex) + val grayCellBg = if (isSelected) Color(0xFFB0C4DE) else Color(0xFFDCDCDC) + val whiteCellBg = if (isSelected) Color(0xFFCCE8FF) else Color.White + + Row( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .border(0.5.dp, Color(0xFFE5E5E5)) + .clickable { selectedIndex = index }, + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.width(widthName).fillMaxHeight().background(grayCellBg).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + Text(arr.name, fontSize = 11.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, color = Color.Black, maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false) + } + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Box(modifier = Modifier.width(widthDesc).fillMaxHeight().background(whiteCellBg).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + TableEditableDescriptionCell(arr.description, { onDescriptionChange(arr, it) }) + } + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Box(modifier = Modifier.width(widthMin).fillMaxHeight().background(whiteCellBg).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + val minStr = if (arr.lowerLimit == arr.lowerLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", arr.lowerLimit) else String.format(Locale.US, "%.3f", arr.lowerLimit) + Text(minStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, softWrap = false) + } + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Box(modifier = Modifier.width(widthMax).fillMaxHeight().background(whiteCellBg).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + val maxStr = if (arr.upperLimit == arr.upperLimit.toLong().toDouble()) String.format(Locale.US, "%.0f", arr.upperLimit) else String.format(Locale.US, "%.3f", arr.upperLimit) + Text(maxStr, fontSize = 11.sp, color = Color.Black, maxLines = 1, softWrap = false) + } + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(whiteCellBg).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) { + Text(String.format(Locale.US, "0x%08X", arr.ecuAddress), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = Color.Black, maxLines = 1, softWrap = false) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // 3. 구분선 (Groove) + HorizontalDivider(thickness = 1.dp, color = Color(0xFFA0A0A0)) + HorizontalDivider(thickness = 1.dp, color = Color.White) + + Spacer(modifier = Modifier.height(4.dp)) + + // 4. 중간 서브타이틀 바 + val arrName = selectedArr?.name ?: "" + Text( + text = "Characteristics array name : $arrName", + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + color = Color.Black, + modifier = Modifier.padding(start = 2.dp, bottom = 4.dp) + ) + + // 5. 하단 2D 동적 매트릭스 그리드 (PC m_ChartListCtrlA 1:1 일치) + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color.White) + .border(1.dp, Color(0xFF7F9DB9)) + ) { + if (selectedArr != null) { + val xSize = maxOf(1, selectedArr.matrixDim.getOrNull(0) ?: maxOf(1, selectedArr.values.size)) + val ySize = maxOf(1, selectedArr.matrixDim.getOrNull(1) ?: 1) + val totalCols = xSize + val matrixTableWidth = (55 + totalCols * 45).dp + + Box( + modifier = Modifier + .fillMaxSize() + .horizontalScroll(rememberScrollState()) + ) { + Column(modifier = Modifier.width(matrixTableWidth)) { + // Header: [ Array | 00 | 01 | 02 ... ] + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(Color(0xFFE2E2E2)) + .border(0.5.dp, Color(0xFFC0C0C0)), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.width(55.dp).fillMaxHeight().background(Color(0xFFDCDCDC)), contentAlignment = Alignment.Center) { + Text("Array", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1) + } + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFC0C0C0))) + + for (x in 0 until xSize) { + Box(modifier = Modifier.width(45.dp).fillMaxHeight(), contentAlignment = Alignment.Center) { + Text(String.format(Locale.US, "%02d", x), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1) + } + if (x < xSize - 1) { + Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFC0C0C0))) + } + } + } + + // Rows (y: 0 until ySize) + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(ySize) { y -> + Row( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .border(0.5.dp, Color(0xFFE5E5E5)), + verticalAlignment = Alignment.CenterVertically + ) { + // Row Index Column + Box( + modifier = Modifier + .width(55.dp) + .fillMaxHeight() + .background(Color(0xFFDCDCDC)), + contentAlignment = Alignment.Center + ) { + Text(String.format(Locale.US, "%02d", y), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1) + } + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + + // Data Cells for this row + for (x in 0 until xSize) { + val cellIdx = y * xSize + x + val cellVal = selectedArr.values.getOrNull(cellIdx) ?: 0.0 + + Box( + modifier = Modifier + .width(45.dp) + .fillMaxHeight() + .background(Color.White) + .clickable { + editingCellIndex = cellIdx + editCellText = if (cellVal == cellVal.toLong().toDouble()) String.format(Locale.US, "%.0f", cellVal) else String.format(Locale.US, "%.2f", cellVal) + editCellError = null + }, + contentAlignment = Alignment.Center + ) { + val valStr = if (cellVal == cellVal.toLong().toDouble()) String.format(Locale.US, "%.0f", cellVal) else String.format(Locale.US, "%.2f", cellVal) + Text(valStr, fontSize = 11.sp, color = Color.Black, maxLines = 1) + } + + if (x < xSize - 1) { + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + } + } + } + } + } + } + } + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No array characteristic selected", fontSize = 11.sp, color = Color.Gray) + } + } + } + } + + // 매트릭스 개별 셀 값 수정 다이얼로그 (Min ~ Max 범위 검증) + if (editingCellIndex != null && selectedArr != null) { + val cellIdx = editingCellIndex!! + AlertDialog( + onDismissRequest = { editingCellIndex = null }, + title = { + Text( + text = "Edit Array Cell [${cellIdx}] - ${selectedArr.name}", + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Range: [${selectedArr.lowerLimit} ~ ${selectedArr.upperLimit}]", + fontSize = 11.sp, + color = Color(0xFF6C757D), + modifier = Modifier.padding(bottom = 8.dp) + ) + OutlinedTextField( + value = editCellText, + onValueChange = { + editCellText = it + val parsed = it.toDoubleOrNull() + editCellError = when { + parsed == null -> "유효한 숫자를 입력하십시오." + parsed < selectedArr.lowerLimit || parsed > selectedArr.upperLimit -> + "범위를 벗어났습니다. [${selectedArr.lowerLimit} ~ ${selectedArr.upperLimit}]" + else -> null + } + }, + isError = editCellError != null, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (editCellError != null) { + Text( + text = editCellError!!, + color = MaterialTheme.colorScheme.error, + fontSize = 11.sp, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + }, + confirmButton = { + Button( + onClick = { + val parsed = editCellText.toDoubleOrNull() + if (parsed != null && parsed >= selectedArr.lowerLimit && parsed <= selectedArr.upperLimit) { + onCellChange(selectedArr, cellIdx, parsed) + editingCellIndex = null + } + }, + enabled = editCellError == null && editCellText.toDoubleOrNull() != null + ) { + Text("적용", fontSize = 12.sp) + } + }, + dismissButton = { + Row { + if (onReadCell != null) TextButton(onClick = { onReadCell(selectedArr, cellIdx); editingCellIndex = null }) { Text("장비에서 읽기") } + TextButton(onClick = { editingCellIndex = null }) { + Text("취소", fontSize = 12.sp) + } + } + } + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalSorting.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalSorting.kt new file mode 100644 index 0000000..8a330b6 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalSorting.kt @@ -0,0 +1,29 @@ +package com.dct.app.presentation.signaltree + +import com.dct.app.domain.model.mdb.SignalDefinition +import java.util.Locale + +internal fun sortCanSignals(signals: List, column: String, descending: Boolean, values: Map): List { + if (column.isBlank()) return signals + val numeric: (SignalDefinition) -> Double? = { s -> when (column) { + "value" -> (values["can_${s.idx}"] ?: values[s.name])?.takeIf { it.isFinite() } + "msgId" -> s.messageId.toDouble() + "min" -> s.min; "max" -> s.max; "startBit" -> s.startBit.toDouble() + "length" -> s.bitLength.toDouble(); "factor" -> s.cvsA; "offset" -> s.cvsB + "color" -> s.color.toDouble() + else -> null + } } + val text: (SignalDefinition) -> String = { s -> when(column) { + "unit" -> s.unit; "desc" -> s.description; "msg" -> s.messageName; else -> s.name + }.lowercase(Locale.ROOT) } + val isNumber = column in setOf("value", "msgId", "min", "max", "startBit", "length", "factor", "offset", "color") + return signals.sortedWith { a, b -> + val x = numeric(a); val y = numeric(b) + if (isNumber && (x == null || y == null)) { + when { x == null && y != null -> 1; x != null && y == null -> -1; else -> a.idx.compareTo(b.idx) } + } else { + val comparison = if (isNumber) x!!.compareTo(y!!) else text(a).compareTo(text(b)) + if (comparison == 0) a.idx.compareTo(b.idx) else if (descending) -comparison else comparison + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalTableView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalTableView.kt new file mode 100644 index 0000000..5e863d7 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalTableView.kt @@ -0,0 +1,232 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.SignalDragDropManager +import java.util.Locale + +/** + * 2. CAN DBC Signal 전용 데이터 테이블 창 (PC ReportDBCSignalFormView 100% 호환) + * 기본: DBC name(100) | Value(60) | Unit(40) | Color(55) | Description(150) + * Expand 시: Message(100) | Message ID(55) | Min(55) | Max(55) | StartBit(55) | Length(55) | Factor(55) | Offset(55) + */ +@Composable +fun WindowsClassicCanSignalTableWindow( + canSignals: List, + liveTelemetryValues: Map, + onSignalClick: (SignalDefinition) -> Unit = {}, + onSignalDrop: ((SignalDefinition) -> Unit)? = null, + onColorChange: (SignalDefinition, Long) -> Unit = { _, _ -> }, + onDescriptionChange: (SignalDefinition, String) -> Unit = { _, _ -> }, + modifier: Modifier = Modifier +) { + var searchQuery by rememberWindowString("search") + var isExpanded by rememberWindowBoolean("expanded") + var selectedSignalKey by rememberWindowNullableString("selected_signal") + + var sortColumn by rememberWindowString("sort_column") + var sortDescending by rememberWindowBoolean("sort_descending") + fun sortByColumn(column: String) { + sortDescending = if (sortColumn == column) !sortDescending else false + sortColumn = column + } + fun columnTitle(column: String, label: String) = label + if (sortColumn == column) { + if (sortDescending) " ▼" else " ▲" + } else "" + val filteredSignals = remember(canSignals, searchQuery, sortColumn, sortDescending, liveTelemetryValues) { + sortCanSignals(canSignals.filter { searchQuery.isBlank() || it.name.contains(searchQuery, true) }, + sortColumn, sortDescending, liveTelemetryValues) + } + + val customWidths = rememberWindowColumnWidths() + + val widthName = customWidths["name"] ?: 110.dp + val widthValue = customWidths["value"] ?: 65.dp + val widthUnit = customWidths["unit"] ?: 45.dp + val widthColor = customWidths["color"] ?: 55.dp + val widthDesc = customWidths["desc"] ?: 160.dp + val widthMsg = customWidths["msg"] ?: 110.dp + val widthMsgId = customWidths["msgId"] ?: 60.dp + val widthMin = customWidths["min"] ?: 55.dp + val widthMax = customWidths["max"] ?: 55.dp + val widthStartBit = customWidths["startBit"] ?: 55.dp + val widthLength = customWidths["length"] ?: 55.dp + val widthFactor = customWidths["factor"] ?: 55.dp + val widthOffset = customWidths["offset"] ?: 55.dp + + fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { + val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) + if (customWidths.containsKey(key)) { + customWidths.remove(key) + } else { + customWidths[key] = autoW + } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White) + ) { + TableSubHeader( + searchQuery = searchQuery, + onSearchChange = { searchQuery = it }, + isExpanded = isExpanded, + onExpandClick = { isExpanded = !isExpanded } + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + val totalWidth = widthName + widthValue + widthUnit + widthColor + widthDesc + + if (isExpanded) (widthMsg + widthMsgId + widthMin + widthMax + widthStartBit + widthLength + widthFactor + widthOffset + 15.dp) else 5.dp + + Column(modifier = Modifier.width(totalWidth)) { + // Table Header + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(Color(0xFFE9ECEF)) + .border(1.dp, Color(0xFFCED4DA)), + verticalAlignment = Alignment.CenterVertically + ) { + ResizableTableHeaderCell(columnTitle("name", "DBC name"), widthName, onClick = { sortByColumn("name") }, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 110.dp, filteredSignals.map { it.name }) }) + ResizableTableHeaderCell(columnTitle("value", "Value"), widthValue, onClick = { sortByColumn("value") }, onWidthChange = { customWidths["value"] = it }, onDoubleTap = { toggleAutoFit("value", 65.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["can_${sig.idx}"] ?: liveTelemetryValues[sig.name]; sig.formatDisplayValue(v) }) }) + ResizableTableHeaderCell(columnTitle("unit", "Unit"), widthUnit, onClick = { sortByColumn("unit") }, onWidthChange = { customWidths["unit"] = it }, onDoubleTap = { toggleAutoFit("unit", 45.dp, filteredSignals.map { it.unit }) }) + ResizableTableHeaderCell(columnTitle("color", "Color"), widthColor, onClick = { sortByColumn("color") }, onWidthChange = { customWidths["color"] = it }, onDoubleTap = { toggleAutoFit("color", 55.dp, emptyList()) }) + ResizableTableHeaderCell(columnTitle("desc", "Description"), widthDesc, onClick = { sortByColumn("desc") }, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 160.dp, filteredSignals.map { it.description }) }, showDivider = isExpanded) + if (isExpanded) { + ResizableTableHeaderCell(columnTitle("msg", "Message"), widthMsg, onClick = { sortByColumn("msg") }, onWidthChange = { customWidths["msg"] = it }, onDoubleTap = { toggleAutoFit("msg", 110.dp, filteredSignals.map { it.messageName.ifEmpty { "Msg_${it.idx}" } }) }) + ResizableTableHeaderCell(columnTitle("msgId", "Message ID"), widthMsgId, onClick = { sortByColumn("msgId") }, onWidthChange = { customWidths["msgId"] = it }, onDoubleTap = { toggleAutoFit("msgId", 60.dp, filteredSignals.map { String.format(Locale.US, "0x%04X", it.messageId) }) }) + ResizableTableHeaderCell(columnTitle("min", "Min"), widthMin, onClick = { sortByColumn("min") }, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.min) }) }) + ResizableTableHeaderCell(columnTitle("max", "Max"), widthMax, onClick = { sortByColumn("max") }, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.max) }) }) + ResizableTableHeaderCell(columnTitle("startBit", "StartBit"), widthStartBit, onClick = { sortByColumn("startBit") }, onWidthChange = { customWidths["startBit"] = it }, onDoubleTap = { toggleAutoFit("startBit", 55.dp, filteredSignals.map { it.startBit.toString() }) }) + ResizableTableHeaderCell(columnTitle("length", "Length"), widthLength, onClick = { sortByColumn("length") }, onWidthChange = { customWidths["length"] = it }, onDoubleTap = { toggleAutoFit("length", 55.dp, filteredSignals.map { it.bitLength.toString() }) }) + ResizableTableHeaderCell(columnTitle("factor", "Factor"), widthFactor, onClick = { sortByColumn("factor") }, onWidthChange = { customWidths["factor"] = it }, onDoubleTap = { toggleAutoFit("factor", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.cvsA) }) }) + ResizableTableHeaderCell(columnTitle("offset", "Offset"), widthOffset, onClick = { sortByColumn("offset") }, onWidthChange = { customWidths["offset"] = it }, onDoubleTap = { toggleAutoFit("offset", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.cvsB) }) }, showDivider = false) + } + } + + // Table Rows + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(filteredSignals, key = { _, s -> "can_${s.idx}_${s.name}" }) { index, sig -> + val sigKey = "can_${sig.idx}" + val isSelected = (sigKey == selectedSignalKey) + val liveVal = liveTelemetryValues[sigKey] ?: liveTelemetryValues[sig.name] + val displayVal = sig.formatDisplayValue(liveVal) + + var rowPosInRoot by remember { mutableStateOf(Offset.Zero) } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(if (isSelected) Color(0xFFCCE8FF) else if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) + .border(0.5.dp, Color(0xFFE5E5E5)) + .onGloballyPositioned { coordinates -> + rowPosInRoot = coordinates.positionInRoot() + } + .pointerInput(sigKey) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + SignalDragDropManager.startDrag( + signal = DraggedSignal( + signalId = sigKey, + signalName = sig.name, + colorLong = sig.color, + unit = sig.unit, + isCan = true, + isA2l = false + ), + startPos = rowPosInRoot + localOffset + ) + }, + onDrag = { change, dragAmount -> + change.consume() + SignalDragDropManager.updateDrag(dragAmount) + }, + onDragEnd = { + SignalDragDropManager.endDrag { + onSignalDrop?.invoke(sig) + } + }, + onDragCancel = { + SignalDragDropManager.cancelDrag() + } + ) + } + .clickable { + selectedSignalKey = sigKey + onSignalClick(sig) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text(sig.name, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthName).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(displayVal, fontSize = 11.sp, color = if (liveVal != null) Color(0xFF212529) else Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthValue).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(sig.unit, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, modifier = Modifier.width(widthUnit).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + EditableTableColorCell( + colorValue = sig.color, + onColorChanged = { newColor -> onColorChange(sig, newColor) }, + modifier = Modifier.width(widthColor) + ) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + TableEditableDescriptionCell( + description = sig.description, + onDescriptionChanged = { newDesc -> onDescriptionChange(sig, newDesc) }, + modifier = Modifier.width(widthDesc) + ) + if (isExpanded) { + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(sig.messageName.ifEmpty { "Msg_${sig.idx}" }, fontSize = 10.sp, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthMsg).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "0x%04X", sig.messageId), fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMsgId).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "%.3f", sig.min), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMin).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "%.3f", sig.max), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMax).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text("${sig.startBit}", fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthStartBit).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text("${sig.bitLength}", fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthLength).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "%.3f", sig.cvsA), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthFactor).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "%.3f", sig.cvsB), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthOffset).padding(horizontal = 4.dp)) + } + } + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationCurveGeometry.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationCurveGeometry.kt new file mode 100644 index 0000000..042712e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationCurveGeometry.kt @@ -0,0 +1,34 @@ +package com.dct.app.presentation.signaltree + +import com.dct.app.domain.model.chart.PlotBounds +import com.dct.app.domain.model.mdb.CompensationMap + +internal data class CompensationCurveGeometry( + val points: List>, + val minX: Double, val maxX: Double, + val minY: Double, val maxY: Double +) + +/** Display-only ranges: PC includes zero, configured Y limits and the actual Y data. */ +internal fun compensationCurveGeometry(map: CompensationMap): CompensationCurveGeometry? { + val points = map.points.map { + if (map.isTypeA) it.phy to it.data.toDouble() else it.bkp.toDouble() to it.phy + } + val finite = points.filter { it.first.isFinite() && it.second.isFinite() } + if (finite.isEmpty()) return null + var minX = finite.minOf { it.first } + var maxX = finite.maxOf { it.first } + val yBounds = finite.map { it.second } + listOf(0.0, map.yMin, map.yMax).filter { it.isFinite() } + var minY = yBounds.minOrNull()!! + var maxY = yBounds.maxOrNull()!! + if (maxX - minX < 1e-6) { minX -= 10.0; maxX += 10.0 } + if (maxY - minY < 1e-6) { minY -= 10.0; maxY += 10.0 } + if (!(maxX - minX).isFinite() || !(maxY - minY).isFinite() || maxX <= minX || maxY <= minY) return null + return CompensationCurveGeometry(points, minX, maxX, minY, maxY) +} + +/** Never manufacture a plot larger than its actual Canvas when a window is shrunk. */ +internal fun compensationPlotBounds(width: Float, height: Float): PlotBounds? { + if (!width.isFinite() || !height.isFinite() || width < 82f || height < 64f) return null + return PlotBounds(60f, 16f, width - 12f, height - 38f) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationMapView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationMapView.kt new file mode 100644 index 0000000..f4ffcdd --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationMapView.kt @@ -0,0 +1,472 @@ +package com.dct.app.presentation.signaltree + +import com.dct.app.domain.model.chart.ChartAxisTicks +import com.dct.app.domain.model.chart.placeAxisLabels +import android.graphics.Paint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.mdb.CompensationMap +import java.util.Locale + +/** + * 5. Compensation Type A & B 전용 데이터 테이블 창 (PC 캡처 화면 1:1 완벽 일치) + * [ 상단 Map 목록 테이블 | 중간 제어 바 (Compensation A/B : + Read/Write X/Y 2열 2행) | 하단: 좌측 2D 그래프 (~68%) + 우측 데이터 테이블 (~32%) ] + */ +@Composable +fun WindowsClassicCompensationTableWindow( + maps: List, + isTypeA: Boolean, + isConnected: Boolean = false, + onReadX: ((CompensationMap) -> Unit)? = null, + onDescriptionChange: (CompensationMap, String) -> Unit = { _, _ -> }, + onReadY: ((CompensationMap) -> Unit)? = null, + onWriteX: ((CompensationMap) -> Unit)? = null, + onWriteY: ((CompensationMap) -> Unit)? = null, + modifier: Modifier = Modifier +) { + var searchQuery by rememberWindowString("search") + var selectedIndex by rememberWindowInt("selected_index") + + val filtered = remember(maps, searchQuery) { + if (searchQuery.isBlank()) maps else maps.filter { it.yName.contains(searchQuery, true) || it.xName.contains(searchQuery, true) } + } + + val selectedMap = if (filtered.isNotEmpty()) { + filtered.getOrNull(selectedIndex) ?: filtered[0] + } else null + + val isBtnEnabled = isConnected && selectedMap != null + var editingPoint by remember(selectedMap) { mutableStateOf(null) } + var editX by remember { mutableStateOf("") } + var editY by remember { mutableStateOf("") } + var editError by remember { mutableStateOf(null) } + + if (editingPoint != null && selectedMap != null) { + val point = editingPoint!! + fun writePoint(xAxis: Boolean) { + try { + val value = (if (xAxis) editX else editY).toDouble() + val attr = if (xAxis) selectedMap.xAttr else selectedMap.yAttr + com.dct.app.data.protocol.DctCalibrationProtocol.encodeInteger(value, attr) + var updated = if (xAxis) point.copy(bkp = value.toLong().toInt()) else point.copy(data = value.toLong().toInt()) + if (xAxis == selectedMap.isTypeA) updated = updated.copy(phy = value * selectedMap.cvsA + selectedMap.cvsB) + val edited = selectedMap.copy(points = selectedMap.points.map { if (it.idx == point.idx) updated else it }) + if (xAxis) onWriteX?.invoke(edited) else onWriteY?.invoke(edited) + editingPoint = null + } catch (e: IllegalArgumentException) { editError = e.message } + } + androidx.compose.material3.AlertDialog( + onDismissRequest = { editingPoint = null }, + title = { Text("보정 포인트 ${point.idx}") }, + text = { Column { + androidx.compose.material3.OutlinedTextField(value = editX, onValueChange = { editX = it; editError = null }, label = { Text("X / BKP (Raw)") }) + androidx.compose.material3.OutlinedTextField(value = editY, onValueChange = { editY = it; editError = null }, label = { Text("Y / Data (Raw)") }) + Text("선택한 축만 장비 확인 후 반영합니다.", fontSize = 12.sp) + editError?.let { Text(it, color = Color.Red) } + } }, + confirmButton = { Row { + androidx.compose.material3.TextButton(onClick = { writePoint(true) }, enabled = isBtnEnabled) { Text("Write X") } + androidx.compose.material3.TextButton(onClick = { writePoint(false) }, enabled = isBtnEnabled) { Text("Write Y") } + } }, + dismissButton = { androidx.compose.material3.TextButton(onClick = { editingPoint = null }) { Text("취소") } } + ) + } + val btnBg = if (isBtnEnabled) Color(0xFFF0F0F0) else Color(0xFFE8E8E8) + val btnBorder = if (isBtnEnabled) Color(0xFF707070) else Color(0xFFB0B0B0) + val btnText = if (isBtnEnabled) Color.Black else Color(0xFF888888) + + Column( + modifier = modifier + .fillMaxSize() + .background(Color(0xFFF0F0F0)) + .padding(6.dp) + ) { + // 1. 상단 검색창 + Box( + modifier = Modifier + .width(130.dp) + .height(24.dp) + .background(Color.White) + .border(1.dp, Color(0xFF7F9DB9)) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + selectedIndex = 0 + }, + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 11.sp, color = Color.Black), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Header-aware widths shared by both map types and their body cells. + CompensationTable( + columns = remember(isTypeA) { compensationMapColumns(isTypeA) }, + rows = filtered, + statePrefix = "comp:map", + modifier = Modifier.fillMaxWidth().height(135.dp), + selectedIndex = selectedIndex, + onRowClick = { index, _ -> selectedIndex = index }, + rowKey = { "comp_${it.idx}_${it.yName}" }, + onDescriptionChange = onDescriptionChange + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // 3. 구분선 (Groove) + HorizontalDivider(thickness = 1.dp, color = Color(0xFFA0A0A0)) + HorizontalDivider(thickness = 1.dp, color = Color.White) + + Spacer(modifier = Modifier.height(4.dp)) + + // 4. 중간 제어 바 (Compensation A/B : + Read/Write 2열 2행 버튼 그룹) + val typeName = if (isTypeA) "Compensation A" else "Compensation B" + val yName = selectedMap?.yName ?: "" + val xName = selectedMap?.xName ?: "" + + Text( + text = "$typeName : $yName", + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + color = Color.Black, + modifier = Modifier.padding(start = 2.dp, bottom = 4.dp) + ) + + // Read/Write 버튼 2x2 배치 (줄바꿈 없이 2개 열로 균등 배치) + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp)) { + // Row 1: Read X | Read Y + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + // Read X (Left Column) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(60.dp) + .height(24.dp) + .background(btnBg, RoundedCornerShape(2.dp)) + .border(1.dp, btnBorder, RoundedCornerShape(2.dp)) + .clickable(enabled = isBtnEnabled) { selectedMap?.let { onReadX?.invoke(it) } }, + contentAlignment = Alignment.Center + ) { + Text("Read X", fontSize = 11.sp, color = btnText) + } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "X-Axis : $xName", + fontSize = 11.sp, + color = Color.Black, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Read Y (Right Column) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(60.dp) + .height(24.dp) + .background(btnBg, RoundedCornerShape(2.dp)) + .border(1.dp, btnBorder, RoundedCornerShape(2.dp)) + .clickable(enabled = isBtnEnabled) { selectedMap?.let { onReadY?.invoke(it) } }, + contentAlignment = Alignment.Center + ) { + Text("Read Y", fontSize = 11.sp, color = btnText) + } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Y-Axis : $yName", + fontSize = 11.sp, + color = Color.Black, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Row 2: Write X | Write Y + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + // Write X (Left Column) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(60.dp) + .height(24.dp) + .background(btnBg, RoundedCornerShape(2.dp)) + .border(1.dp, btnBorder, RoundedCornerShape(2.dp)) + .clickable(enabled = isBtnEnabled) { selectedMap?.let { onWriteX?.invoke(it) } }, + contentAlignment = Alignment.Center + ) { + Text("Write X", fontSize = 11.sp, color = btnText) + } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "X-Axis : $xName", + fontSize = 11.sp, + color = Color.Black, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Write Y (Right Column) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(60.dp) + .height(24.dp) + .background(btnBg, RoundedCornerShape(2.dp)) + .border(1.dp, btnBorder, RoundedCornerShape(2.dp)) + .clickable(enabled = isBtnEnabled) { selectedMap?.let { onWriteY?.invoke(it) } }, + contentAlignment = Alignment.Center + ) { + Text("Write Y", fontSize = 11.sp, color = btnText) + } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Y-Axis : $yName", + fontSize = 11.sp, + color = Color.Black, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + // 5. 하단 영역: [ 좌측: 2D 보정 곡선 그래프 (~68%) | 우측: 데이터 포인트 표 (~32%) ] + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + // 5.1 좌측 2D 그래프 (PC CChartCtrl 스타일) + Box( + modifier = Modifier + .weight(0.68f) + .fillMaxHeight() + .background(Color.White) + .border(1.dp, Color(0xFF7F9DB9)) + .padding(2.dp) + ) { + if (selectedMap != null && selectedMap.points.isNotEmpty()) { + CompensationCurveCanvas( + map = selectedMap, + modifier = Modifier.fillMaxSize() + ) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No map data available", fontSize = 11.sp, color = Color.Gray) + } + } + } + + Spacer(modifier = Modifier.width(6.dp)) + + // Minimum column widths are preserved; narrow tables scroll horizontally. + CompensationTable( + columns = remember(isTypeA) { compensationPointColumns(isTypeA) }, + rows = selectedMap?.points ?: emptyList(), + statePrefix = "comp:point", + modifier = Modifier.weight(0.32f).fillMaxHeight(), + onRowClick = { _, pt -> + fun rawText(value: Int, unsigned: Boolean) = + if (unsigned) (value.toLong() and 0xFFFFFFFFL).toString() else value.toString() + editX = rawText(pt.bkp, selectedMap?.xAttr == com.dct.app.domain.model.mdb.MdbAttrType.ATTR_4_UINT32) + editY = rawText(pt.data, selectedMap?.yAttr == com.dct.app.domain.model.mdb.MdbAttrType.ATTR_4_UINT32) + editError = null + editingPoint = pt + } + ) + } + } +} + +/** + * 2D 보정 곡선 실시간 캔버스 렌더러 (PC CChartCtrl 1:1 완벽 일치) + * - 좌측 Y축: 세로 회전 라벨(YNAME), 수치 눈금 (겹침 없이 완벽 분리) + * - 하단 X축: 중앙 가로 라벨(XNAME), 수치 눈금 + * - 2px Solid Black 플롯 박스 + 회색 점선 그리드 + 실시간 보정 커브 + */ +@Composable +internal fun CompensationCurveCanvas( + map: CompensationMap, + modifier: Modifier = Modifier +) { + val tickPaint = remember { + Paint().apply { + isAntiAlias = true + textSize = 17f + color = android.graphics.Color.BLACK + } + } + val axisLabelPaint = remember { + Paint().apply { + isAntiAlias = true + textSize = 21f + typeface = android.graphics.Typeface.DEFAULT_BOLD + color = android.graphics.Color.BLACK + } + } + + val geometry = remember(map) { compensationCurveGeometry(map) } + Canvas(modifier = modifier.clipToBounds()) { + val curve = geometry ?: return@Canvas + val bounds = compensationPlotBounds(size.width, size.height) ?: return@Canvas + val h = size.height + val padL = bounds.left + val padT = bounds.top + val plotW = bounds.width + val plotH = bounds.height + val minX = curve.minX + val maxX = curve.maxX + val minY = curve.minY + val maxY = curve.maxY + + val spanX = maxX - minX + val spanY = maxY - minY + + // 1. 그리드 점선 및 Y축 눈금 + val yTicks = 4 + for (i in 0..yTicks) { + val y = padT + plotH * (i / yTicks.toFloat()) + drawLine( + color = Color(0xFF888888), + start = Offset(padL, y), + end = Offset(padL + plotW, y), + strokeWidth = 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 3f), 0f) + ) + val tickYVal = maxY - (spanY * (i / yTicks.toDouble())) + drawIntoCanvas { canvas -> + val str = formatCompensationPhysicalValue(tickYVal) + val numWidth = tickPaint.measureText(str) + canvas.nativeCanvas.drawText(str, padL - numWidth - 5f, y + 5f, tickPaint) + } + } + + // 2. 그리드 점선 및 X축 눈금 + val xTicks = ChartAxisTicks.numeric(minX, maxX, plotW) + val centers = xTicks.values.map { padL + ((it - minX) / spanX * plotW).toFloat() } + val labels = xTicks.values.map { xTicks.label(it) } + val placements = placeAxisLabels(centers, labels.map { tickPaint.measureText(it) }, padL, padL + plotW) + .associateBy { it.index } + for ((index, x) in centers.withIndex()) { + drawLine( + color = Color(0xFF888888), + start = Offset(x, padT), + end = Offset(x, padT + plotH), + strokeWidth = 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 3f), 0f) + ) + val placement = placements[index] ?: continue + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawText(labels[index], placement.left, padT + plotH + 16f, tickPaint) + } + } + + // Draw axes/frame before the curve so boundary samples remain visible. + drawRect( + color = Color.Black, + topLeft = Offset(padL, padT), + size = androidx.compose.ui.geometry.Size(plotW, plotH), + style = Stroke(width = 2f) + ) + + // Clip the actual curve, rather than clamping values to the edge and distorting it. + val curveColor = if (map.isTypeA) Color(0xFFFF0000) else Color(0xFF0000FF) + val path = Path() + var first = true + for ((vx, vy) in curve.points) { + if (!vx.isFinite() || !vy.isFinite()) { + first = true + continue + } + val px = padL + ((vx - minX) / spanX * plotW).toFloat() + val py = padT + plotH - ((vy - minY) / spanY * plotH).toFloat() + if (first) { + path.moveTo(px, py) + first = false + } else path.lineTo(px, py) + } + clipRect(bounds.left, bounds.top, bounds.right, bounds.bottom) { + drawPath(path, curveColor, style = Stroke(width = 3f)) + } + + // 5. Y축 및 X축 라벨 렌더링 + drawIntoCanvas { canvas -> + // Y축 세로 회전 라벨 (맨 좌측 cx = 13f에 깔끔하게 배치) + canvas.nativeCanvas.save() + val yLabel = map.yName + val yLabelWidth = axisLabelPaint.measureText(yLabel) + val cx = 13f + val cy = padT + plotH / 2f + canvas.nativeCanvas.rotate(-90f, cx, cy) + canvas.nativeCanvas.drawText(yLabel, cx - yLabelWidth / 2f, cy + 6f, axisLabelPaint) + canvas.nativeCanvas.restore() + + // X축 하단 중앙 라벨 + val xLabel = map.xName + val xLabelWidth = axisLabelPaint.measureText(xLabel) + val bx = padL + plotW / 2f - xLabelWidth / 2f + val by = h - 5f + canvas.nativeCanvas.drawText(xLabel, bx, by, axisLabelPaint) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationNumberFormat.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationNumberFormat.kt new file mode 100644 index 0000000..be232ac --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationNumberFormat.kt @@ -0,0 +1,13 @@ +package com.dct.app.presentation.signaltree + +import java.util.Locale + +/** PC compensation PHY: up to four fractional digits, without insignificant trailing zeros. */ +internal fun formatCompensationPhysicalValue(value: Double): String { + if (!value.isFinite()) return "N/A" + val formatted = String.format(Locale.US, "%.4f", value).trimEnd('0').trimEnd('.') + return if (formatted == "-0") "0" else formatted +} + +internal fun formatCompensationCoefficient(value: Double, isTypeA: Boolean): String = + if (value.isFinite()) String.format(Locale.US, if (isTypeA) "%.4f" else "%.3f", value) else "N/A" diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationTable.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationTable.kt new file mode 100644 index 0000000..de18f0d --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationTable.kt @@ -0,0 +1,147 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.mdb.CompensationMap +import com.dct.app.domain.model.mdb.MapPoint +import java.util.Locale + +internal data class CompensationColumn( + val title: String, + val defaultWidth: Float, + val gray: Boolean = false, + val text: (T) -> String +) + +internal fun compensationMapColumns(isTypeA: Boolean): List> { + val y = listOf( + CompensationColumn("YNAME", 115f, true) { it.yName }, + CompensationColumn("YADDR", 80f) { it.yAddr.toString() }, + CompensationColumn("YATTR", 55f, true) { it.yAttr.code.toString() }, + CompensationColumn("YMIN", 65f) { String.format(Locale.US, "%.0f", it.yMin) }, + CompensationColumn("YMAX", 65f) { String.format(Locale.US, "%.0f", it.yMax) } + ) + val x = listOf( + CompensationColumn("XNAME", 115f, true) { it.xName }, + CompensationColumn("XADDR", 80f) { it.xAddr.toString() }, + CompensationColumn("XATTR", 55f, true) { it.xAttr.code.toString() } + ) + val conversion = listOf( + CompensationColumn("CVS_A", 80f) { formatCompensationCoefficient(it.cvsA, isTypeA) }, + CompensationColumn("CVS_B", 80f) { formatCompensationCoefficient(it.cvsB, isTypeA) } + ) + return y + (if (isTypeA) x + conversion else conversion + x) + + CompensationColumn("DESCRIPTION", 180f) { it.description } +} + +internal fun compensationPointColumns(isTypeA: Boolean): List> { + val index = CompensationColumn("IDX", 48f, true) { it.idx.toString() } + val phy = CompensationColumn("PHY", 80f) { formatCompensationPhysicalValue(it.phy) } + val bkp = CompensationColumn("BKP", 80f, true) { it.bkp.toString() } + val data = CompensationColumn("Data", 80f) { it.data.toString() } + return if (isTypeA) listOf(index, phy, bkp, data) else listOf(index, bkp, data, phy) +} + +/** Header and body share widths, scroll and divider positions; header labels are never squeezed. */ +@Composable +internal fun CompensationTable( + columns: List>, + rows: List, + statePrefix: String, + modifier: Modifier = Modifier, + selectedIndex: Int = -1, + onRowClick: (Int, T) -> Unit, + rowKey: ((T) -> Any)? = null, + onDescriptionChange: ((T, String) -> Unit)? = null +) { + val density = LocalDensity.current + val measurer = rememberTextMeasurer() + val widths = rememberWindowColumnWidths() + val style = TextStyle(fontSize = 11.sp, lineHeight = 14.sp, + platformStyle = androidx.compose.ui.text.PlatformTextStyle(includeFontPadding = false)) + val headerStyle = style.copy(fontWeight = FontWeight.Bold) + fun measuredWidth(text: String) = with(density) { + measurer.measure(text, headerStyle, maxLines = 1, softWrap = false).size.width.toDp() + } + // Includes the 8dp text padding and the 12dp resize grip of the common header. + val minima = columns.map { maxOf(32.dp, measuredWidth(it.title) + 20.dp) } + val columnWidths = columns.mapIndexed { index, column -> + maxOf(minima[index], widths["$statePrefix:${column.title}"] ?: column.defaultWidth.dp) + } + val rowHeight = with(density) { + maxOf(24.dp, measurer.measure("Ag", headerStyle).size.height.toDp() + 8.dp) + } + + BoxWithConstraints(modifier.background(Color.White).border(1.dp, Color(0xFF7F9DB9))) { + val totalWidth = maxOf(maxWidth, columnWidths.sumOf { it.value.toDouble() }.toFloat().dp) + val displayedWidths = columnWidths.toMutableList().also { + it[it.lastIndex] += totalWidth - columnWidths.sumOf { width -> width.value.toDouble() }.toFloat().dp + } + Box(Modifier.fillMaxSize().horizontalScroll(rememberScrollState())) { + Column(Modifier.width(totalWidth).fillMaxHeight()) { + Row(Modifier.fillMaxWidth().height(rowHeight).background(Color(0xFFE2E2E2))) { + columns.forEachIndexed { index, column -> + val key = "$statePrefix:${column.title}" + ResizableTableHeaderCell( + title = column.title, width = displayedWidths[index], + textStyle = headerStyle, + modifier = Modifier.testTag("$statePrefix:header:${column.title}"), + onDoubleTap = { + val contentWidth = rows.maxOfOrNull { measuredWidth(column.text(it)).value } ?: 0f + widths[key] = maxOf(minima[index], (contentWidth + 20f).coerceAtMost(1200f).dp) + }, + onWidthChange = { widths[key] = maxOf(minima[index], it) } + ) + } + } + LazyColumn(Modifier.fillMaxWidth().weight(1f)) { + itemsIndexed(rows, key = { index, row -> rowKey?.invoke(row) ?: index }) { index, row -> + val selected = selectedIndex == index + Row(Modifier.fillMaxWidth().height(rowHeight).clickable { onRowClick(index, row) }) { + columns.forEachIndexed { columnIndex, column -> + val bg = if (selected) { + if (column.gray) Color(0xFFB0C4DE) else Color(0xFFCCE8FF) + } else if (column.gray) Color(0xFFDCDCDC) else Color.White + Row(Modifier.width(displayedWidths[columnIndex]).fillMaxHeight().background(bg)) { + Box(Modifier.weight(1f).fillMaxHeight().padding(horizontal = 4.dp), Alignment.CenterStart) { + if (column.title == "DESCRIPTION" && onDescriptionChange != null) { + TableEditableDescriptionCell( + column.text(row), { onDescriptionChange(row, it) }, + modifier = Modifier.fillMaxWidth().testTag("$statePrefix:description:$index"), + commitOnChange = true + ) + } else Text( + column.text(row), color = Color.Black, style = style, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis + ) + } + Box(Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFC0C0C0))) + } + } + } + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantPresentation.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantPresentation.kt new file mode 100644 index 0000000..57b431c --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantPresentation.kt @@ -0,0 +1,23 @@ +package com.dct.app.presentation.signaltree + +import com.dct.app.domain.model.mdb.MdbConstant +import java.util.Locale +import kotlin.math.floor + +internal fun formatConstantInteger(value: Double): String = + if (value.isFinite() && value == floor(value)) String.format(Locale.US, "%.0f", if (value == 0.0) 0.0 else value) else "N/A" + +internal fun constantInputError(text: String, constant: MdbConstant): String? { + val value = text.toDoubleOrNull() + if (value == null || !value.isFinite()) return "유효한 정수를 입력하세요." + if (value != floor(value)) return "소수 값은 전송할 수 없습니다. 정수를 입력하세요." + if (!constant.min.isFinite() || !constant.max.isFinite() || value !in constant.min..constant.max) + return "허용 범위 [${formatConstantInteger(constant.min)} ~ ${formatConstantInteger(constant.max)}]를 벗어났습니다." + val bits = constant.attr.byteSize * 8 + val min = if (constant.attr.isSigned) -(1L shl (bits - 1)) else 0L + val max = if (constant.attr.isSigned) (1L shl (bits - 1)) - 1 else (1L shl bits) - 1 + return if (value < min.toDouble() || value > max.toDouble()) "${constant.attr.name} 범위 [$min ~ $max]를 벗어났습니다." else null +} + +internal fun filterConstants(constants: List, search: String, checked: Set, onlyChecked: Boolean) = + constants.filter { if (onlyChecked) it.idx.toLong() in checked else it.name.contains(search, ignoreCase = true) } diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantTableView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantTableView.kt new file mode 100644 index 0000000..41f9c30 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantTableView.kt @@ -0,0 +1,190 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.mdb.MdbConstant +import java.util.Locale + +private data class ConstantColumn(val key: String, val title: String, val defaultWidth: Float, val value: (MdbConstant) -> String) + +/** PC column order, integer formatting and compact/expanded view, with tablet editing controls. */ +@Composable +fun WindowsClassicConstantTableWindow( + constants: List, + onValueChange: ((MdbConstant, Double, (String?) -> Unit) -> Unit)? = null, + onRead: ((MdbConstant, (String?) -> Unit) -> Unit)? = null, + onDescriptionChange: (MdbConstant, String) -> Unit = { _, _ -> }, + modifier: Modifier = Modifier, + canRequestDevice: Boolean = false, + isCalibrationBusy: Boolean = false +) { + var searchQuery by rememberWindowString("search") + var expanded by rememberWindowBoolean("expanded") + var checked by rememberWindowChecked("checked_constants") + var onlyChecked by rememberWindowBoolean("only_checked") + var editingKey by remember { mutableStateOf(null) } + var editValueText by remember { mutableStateOf("") } + var pending by remember { mutableStateOf(false) } + var feedback by remember { mutableStateOf(null) } + var requestFailed by remember { mutableStateOf(false) } + var refreshRead by remember { mutableStateOf(false) } + fun identity(con: MdbConstant) = "${con.idx}:${con.addr}:${con.name}" + val filtered = remember(constants, searchQuery, checked, onlyChecked) { + filterConstants(constants, searchQuery, checked, onlyChecked) + } + val editing = constants.find { identity(it) == editingKey } + LaunchedEffect(editingKey, constants, refreshRead) { + if (editingKey != null && editing == null) { editingKey = null; pending = false } + if (refreshRead && editing != null) { editValueText = formatConstantInteger(editing.data); refreshRead = false } + } + + val columns = remember(expanded) { + listOf( + ConstantColumn("name", "Name", 110f) { it.name }, + ConstantColumn("value", "Value", 90f) { formatConstantInteger(it.data) }, + ConstantColumn("desc", "Description", 180f) { it.description } + ) + if (expanded) listOf( + ConstantColumn("min", "Min", 80f) { formatConstantInteger(it.min) }, + ConstantColumn("max", "Max", 80f) { formatConstantInteger(it.max) }, + ConstantColumn("addr", "Address", 110f) { String.format(Locale.US, "0x%08X", it.addr) } + ) else emptyList() + } + val widths = rememberWindowColumnWidths() + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val style = TextStyle(fontSize = 11.sp, lineHeight = 14.sp, platformStyle = PlatformTextStyle(includeFontPadding = false)) + val bold = style.copy(fontWeight = FontWeight.Bold) + fun textWidth(text: String) = with(density) { measurer.measure(text, bold, maxLines = 1, softWrap = false).size.width.toDp() } + val minimumWidths = columns.map { maxOf(32.dp, textWidth(it.title) + 20.dp) } + val actualWidths = columns.mapIndexed { i, col -> maxOf(minimumWidths[i], widths[col.key] ?: col.defaultWidth.dp) } + val rowHeight = with(density) { maxOf(32.dp, measurer.measure("Ag", bold).size.height.toDp() + 8.dp) } + + Column(modifier.fillMaxSize().background(Color.White)) { + // PC disables search while showing checked items; retain its previous text for later. + TableSubHeader(searchQuery, { if (!onlyChecked) searchQuery = it }, + isExpanded = expanded, onExpandClick = { expanded = !expanded }, searchEnabled = !onlyChecked) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(onlyChecked, { onlyChecked = it }, Modifier.testTag("constant:only-checked")) + Text("체크한 항목만 표시", fontSize = 12.sp) + } + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { + val baseWidth = actualWidths.sumOf { it.value.toDouble() }.toFloat().dp + 40.dp + val tableWidth = maxOf(maxWidth, baseWidth) + val displayWidths = actualWidths.toMutableList().also { it[it.lastIndex] += tableWidth - baseWidth } + Box(Modifier.fillMaxSize().horizontalScroll(rememberScrollState())) { + Column(Modifier.width(tableWidth).fillMaxHeight()) { + Row(Modifier.fillMaxWidth().height(rowHeight).background(Color(0xFFE9ECEF))) { + Box(Modifier.width(40.dp).fillMaxHeight()) + columns.forEachIndexed { i, col -> + ResizableTableHeaderCell(col.title, displayWidths[i], modifier = Modifier.testTag("constant:header:${col.key}"), + textStyle = bold, + onDoubleTap = { + val content = filtered.maxOfOrNull { textWidth(col.value(it)).value } ?: 0f + widths[col.key] = maxOf(minimumWidths[i], (content + 20f).coerceAtMost(1200f).dp) + }, onWidthChange = { widths[col.key] = maxOf(minimumWidths[i], it) }) + } + } + LazyColumn(Modifier.weight(1f).fillMaxWidth()) { + itemsIndexed(filtered, key = { _, con -> identity(con) }) { index, con -> + Row(Modifier.fillMaxWidth().height(rowHeight) + .background(if (index % 2 == 0) Color.White else Color(0xFFF8F9FA))) { + Box(Modifier.width(40.dp).fillMaxHeight(), Alignment.Center) { + Checkbox(con.idx.toLong() in checked, { selected -> + checked = if (selected) checked + con.idx.toLong() else checked - con.idx.toLong() + }, Modifier.size(32.dp).testTag("constant:check:${con.idx}")) + } + columns.forEachIndexed { i, col -> + Row(Modifier.width(displayWidths[i]).fillMaxHeight()) { + val cellModifier = Modifier.weight(1f).fillMaxHeight().then( + if (col.key == "value") Modifier.clickable { + editingKey = identity(con) + editValueText = formatConstantInteger(con.data) + feedback = null + requestFailed = false + } else Modifier + ).padding(horizontal = 4.dp).testTag("constant:${col.key}:${con.idx}") + Box(cellModifier, Alignment.CenterStart) { + if (col.key == "desc") { + TableEditableDescriptionCell(con.description, { onDescriptionChange(con, it) }, + Modifier.fillMaxWidth(), commitOnChange = true, enabled = !isCalibrationBusy) + } else Text(col.value(con), style = if (col.key == "value") bold else style, + color = if (col.key == "value") Color(0xFF0052CC) else Color(0xFF212529), + maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + } + Box(Modifier.width(1.dp).fillMaxHeight().background(Color(0xFFCED4DA))) + } + } + } + } + } + } + } + } + } + + editing?.let { con -> + val error = constantInputError(editValueText, con) + val available = canRequestDevice && !isCalibrationBusy && !pending + fun completed(message: String?, read: Boolean) { + pending = false + requestFailed = message != null + feedback = message ?: if (read) "장비 값을 읽었습니다." else "장비에 값을 반영했습니다." + if (message == null && read) refreshRead = true + } + AlertDialog( + onDismissRequest = { if (!pending) editingKey = null }, + title = { Text("Edit Constant - ${con.name}", fontSize = 14.sp) }, + text = { + Column { + Text("Address: ${String.format(Locale.US, "0x%08X", con.addr)}", fontSize = 11.sp) + Text("Range: [${formatConstantInteger(con.min)} ~ ${formatConstantInteger(con.max)}]", fontSize = 11.sp) + OutlinedTextField(editValueText, { editValueText = it; feedback = null }, + label = { Text("Constant Value") }, singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + enabled = !pending, isError = error != null, modifier = Modifier.testTag("constant:input")) + error?.let { Text(it, color = MaterialTheme.colorScheme.error, fontSize = 12.sp) } + if (pending) { LinearProgressIndicator(Modifier.fillMaxWidth()); Text("장비 응답 대기 중", fontSize = 12.sp) } + else if (!canRequestDevice) Text("Wi-Fi 장비 연결 후 계측을 정지하세요.", fontSize = 12.sp) + else if (isCalibrationBusy) Text("다른 보정 요청이 진행 중입니다.", fontSize = 12.sp) + feedback?.let { Text(it, color = if (requestFailed) MaterialTheme.colorScheme.error else Color(0xFF006400), fontSize = 12.sp) } + } + }, + confirmButton = { + Button(onClick = { + pending = true; feedback = null + onValueChange?.invoke(con, editValueText.toDouble()) { completed(it, false) } + }, enabled = available && error == null && onValueChange != null, modifier = Modifier.testTag("constant:write")) { Text("장비에 쓰기") } + }, + dismissButton = { + Row { + TextButton(onClick = { + pending = true; feedback = null + onRead?.invoke(con) { completed(it, true) } + }, enabled = available && onRead != null, modifier = Modifier.testTag("constant:read")) { Text("장비에서 읽기") } + TextButton(onClick = { editingKey = null }, enabled = !pending) { Text("닫기") } + } + } + ) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/MdbSignalTableView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/MdbSignalTableView.kt new file mode 100644 index 0000000..3b987d7 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/MdbSignalTableView.kt @@ -0,0 +1,214 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.SignalDragDropManager +import java.util.Locale + +/** + * 1. MDB Signal 전용 데이터 테이블 창 (PC ReportMDBSignalFormView 100% 호환) + * 기본: Signal name(100) | Value(60) | Unit(40) | Color(55) | Description(150) + * Expand 시: Min(55) | Max(55) | ReclID(50) | Index(50) | Value (Hex)(60) + */ +@Composable +fun WindowsClassicMdbSignalTableWindow( + database: MdbDatabase?, + liveTelemetryValues: Map, + onSignalClick: (SignalDefinition) -> Unit = {}, + onSignalDrop: ((SignalDefinition) -> Unit)? = null, + onColorChange: (SignalDefinition, Long) -> Unit = { _, _ -> }, + onDescriptionChange: (SignalDefinition, String) -> Unit = { _, _ -> }, + modifier: Modifier = Modifier +) { + var searchQuery by rememberWindowString("search") + var isExpanded by rememberWindowBoolean("expanded") + var selectedSignalKey by rememberWindowNullableString("selected_signal") + val signals = database?.allSignals ?: emptyList() + + val filteredSignals = remember(signals, searchQuery) { + if (searchQuery.isBlank()) signals else signals.filter { it.name.contains(searchQuery, true) } + } + + val customWidths = rememberWindowColumnWidths() + + fun toggleAutoFit(key: String, defaultW: Dp, items: List, extraPadding: Dp = 16.dp) { + val autoW = calculateAutoFitWidth(key, items, defaultW, extraPadding = extraPadding) + if (customWidths.containsKey(key)) { + customWidths.remove(key) + } else { + customWidths[key] = autoW + } + } + + val widthName = customWidths["name"] ?: 110.dp + val widthValue = customWidths["value"] ?: 65.dp + val widthUnit = customWidths["unit"] ?: 45.dp + val widthColor = customWidths["color"] ?: 55.dp + val widthDesc = customWidths["desc"] ?: 160.dp + val widthMin = customWidths["min"] ?: 55.dp + val widthMax = customWidths["max"] ?: 55.dp + val widthReclid = customWidths["reclid"] ?: 50.dp + val widthIdx = customWidths["idx"] ?: 50.dp + val widthHex = customWidths["hex"] ?: 60.dp + + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White) + ) { + TableSubHeader( + searchQuery = searchQuery, + onSearchChange = { searchQuery = it }, + isExpanded = isExpanded, + onExpandClick = { isExpanded = !isExpanded } + ) + + // 가로 스크롤 가능한 테이블 컨테이너 + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + val totalWidth = widthName + widthValue + widthUnit + widthColor + widthDesc + + if (isExpanded) (widthMin + widthMax + widthReclid + widthIdx + widthHex + 10.dp) else 5.dp + + Column(modifier = Modifier.width(totalWidth)) { + // Table Header + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(Color(0xFFE9ECEF)) + .border(1.dp, Color(0xFFCED4DA)), + verticalAlignment = Alignment.CenterVertically + ) { + ResizableTableHeaderCell("Signal name", widthName, onWidthChange = { customWidths["name"] = it }, onDoubleTap = { toggleAutoFit("name", 110.dp, filteredSignals.map { it.name }) }) + ResizableTableHeaderCell("Value", widthValue, onWidthChange = { customWidths["value"] = it }, onDoubleTap = { toggleAutoFit("value", 65.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["sig_${sig.reclid}_${sig.idx}"] ?: liveTelemetryValues[sig.name]; sig.formatDisplayValue(v) }) }) + ResizableTableHeaderCell("Unit", widthUnit, onWidthChange = { customWidths["unit"] = it }, onDoubleTap = { toggleAutoFit("unit", 45.dp, filteredSignals.map { it.unit }) }) + ResizableTableHeaderCell("Color", widthColor, onWidthChange = { customWidths["color"] = it }, onDoubleTap = { toggleAutoFit("color", 55.dp, emptyList()) }) + ResizableTableHeaderCell("Description", widthDesc, onWidthChange = { customWidths["desc"] = it }, onDoubleTap = { toggleAutoFit("desc", 160.dp, filteredSignals.map { it.description }) }, showDivider = isExpanded) + if (isExpanded) { + ResizableTableHeaderCell("Min", widthMin, onWidthChange = { customWidths["min"] = it }, onDoubleTap = { toggleAutoFit("min", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.min) }) }) + ResizableTableHeaderCell("Max", widthMax, onWidthChange = { customWidths["max"] = it }, onDoubleTap = { toggleAutoFit("max", 55.dp, filteredSignals.map { String.format(Locale.US, "%.3f", it.max) }) }) + ResizableTableHeaderCell("ReclID", widthReclid, onWidthChange = { customWidths["reclid"] = it }, onDoubleTap = { toggleAutoFit("reclid", 50.dp, filteredSignals.map { it.reclid.toString() }) }) + ResizableTableHeaderCell("Index", widthIdx, onWidthChange = { customWidths["idx"] = it }, onDoubleTap = { toggleAutoFit("idx", 50.dp, filteredSignals.map { it.idx.toString() }) }) + ResizableTableHeaderCell("Value (Hex)", widthHex, onWidthChange = { customWidths["hex"] = it }, onDoubleTap = { toggleAutoFit("hex", 60.dp, filteredSignals.map { sig -> val v = liveTelemetryValues["sig_${sig.reclid}_${sig.idx}"] ?: liveTelemetryValues[sig.name]; sig.formatRawHex(liveTelemetryValues["raw_sig_${sig.reclid}_${sig.idx}"]?.toLong()) }) }, showDivider = false) + } + } + + // Table Rows + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(filteredSignals, key = { _, s -> "mdb_${s.reclid}_${s.idx}_${s.name}" }) { index, sig -> + val sigKey = "sig_${sig.reclid}_${sig.idx}" + val isSelected = (sigKey == selectedSignalKey) + val liveVal = liveTelemetryValues[sigKey] ?: liveTelemetryValues[sig.name] + val displayVal = sig.formatDisplayValue(liveVal) + val hexVal = sig.formatRawHex(liveTelemetryValues["raw_$sigKey"]?.toLong()) + + var rowPosInRoot by remember { mutableStateOf(Offset.Zero) } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .background(if (isSelected) Color(0xFFCCE8FF) else if (index % 2 == 0) Color.White else Color(0xFFF8F9FA)) + .border(0.5.dp, Color(0xFFE5E5E5)) + .onGloballyPositioned { coordinates -> + rowPosInRoot = coordinates.positionInRoot() + } + .pointerInput(sigKey) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + SignalDragDropManager.startDrag( + signal = DraggedSignal( + signalId = sigKey, + signalName = sig.name, + colorLong = sig.color, + unit = sig.unit, + isCan = false, + isA2l = false + ), + startPos = rowPosInRoot + localOffset + ) + }, + onDrag = { change, dragAmount -> + change.consume() + SignalDragDropManager.updateDrag(dragAmount) + }, + onDragEnd = { + SignalDragDropManager.endDrag { + onSignalDrop?.invoke(sig) + } + }, + onDragCancel = { + SignalDragDropManager.cancelDrag() + } + ) + } + .clickable { + selectedSignalKey = sigKey + onSignalClick(sig) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text(sig.name, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(widthName).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(displayVal, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, modifier = Modifier.width(widthValue).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(sig.unit, fontSize = 11.sp, color = Color(0xFF212529), maxLines = 1, modifier = Modifier.width(widthUnit).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + EditableTableColorCell( + colorValue = sig.color, + onColorChanged = { newColor -> onColorChange(sig, newColor) }, + modifier = Modifier.width(widthColor) + ) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + TableEditableDescriptionCell( + description = sig.description, + onDescriptionChanged = { newDesc -> onDescriptionChange(sig, newDesc) }, + modifier = Modifier.width(widthDesc) + ) + if (isExpanded) { + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "%.3f", sig.min), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMin).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "%.3f", sig.max), fontSize = 10.sp, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthMax).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "0x%02X", sig.reclid), fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthReclid).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(String.format(Locale.US, "0x%02X", sig.idx), fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthIdx).padding(horizontal = 4.dp)) + Box(modifier = Modifier.width(0.5.dp).fillMaxHeight().background(Color(0xFFE5E5E5))) + Text(hexVal, fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF6C757D), maxLines = 1, modifier = Modifier.width(widthHex).padding(horizontal = 4.dp)) + } + } + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeModels.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeModels.kt new file mode 100644 index 0000000..1b4329e --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeModels.kt @@ -0,0 +1,82 @@ +package com.dct.app.presentation.signaltree + +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.mdb.CompensationMap +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.MdbConstant +import com.dct.app.domain.model.mdb.SignalDefinition + +/** + * 신호 트리 노드 타입 + */ +enum class SignalTreeNodeType(val displayName: String) { + ROOT_CATEGORY("대분류"), + GROUP("신호 그룹"), + SIGNAL("계측 신호"), + CONSTANT("제어 상수"), + MAP_TYPE_A("1D 보정 맵 (Type A: PxDy)"), + MAP_TYPE_B("1D 보정 맵 (Type B: PyDx)") +} + +/** + * 신호 탐색기 필터 모드 + */ +enum class SignalTreeFilterMode(val label: String) { + ALL("전체"), + SIGNALS_ONLY("신호 (VarLst)"), + CONSTANTS_ONLY("상수 (ConLst)"), + MAPS_ONLY("보정 맵 (Tb1Dm)") +} + +/** + * 계층형 신호 트리 노드 엔티티 + */ +data class SignalTreeNode( + val id: String, + val name: String, + val type: SignalTreeNodeType, + val description: String = "", + val unit: String = "", + val attr: MdbAttrType? = null, + val cvsA: Double = 1.0, + val cvsB: Double = 0.0, + val min: Double = 0.0, + val max: Double = 100.0, + val color: Long = 0xFF0052CCL, + val currentValue: Double = 0.0, + val groupReclid: Int? = null, + val parentId: String? = null, + val isExpanded: Boolean = false, + val children: List = emptyList(), + val level: Int = 0, + // 원본 MDB 도메인 참조 (인스펙터 및 편집기 전달용) + val rawSignal: SignalDefinition? = null, + val rawConstant: MdbConstant? = null, + val rawMap: CompensationMap? = null +) { + val isLeaf: Boolean get() = children.isEmpty() + val totalChildCount: Int get() = children.size + val isGroupOrRoot: Boolean get() = type == SignalTreeNodeType.GROUP || type == SignalTreeNodeType.ROOT_CATEGORY +} + +/** + * 트리 선택 이벤트 페이로드 + */ +sealed interface TreeItemSelection { + data class SignalSelected(val signal: SignalDefinition, val node: SignalTreeNode) : TreeItemSelection + data class ConstantSelected(val constant: MdbConstant, val node: SignalTreeNode) : TreeItemSelection + data class MapSelected(val map: CompensationMap, val node: SignalTreeNode) : TreeItemSelection + data class GroupSelected(val groupName: String, val node: SignalTreeNode) : TreeItemSelection +} + +/** + * 차트 축 바인딩 요청 이벤트 + */ +data class AddToChartRequest( + val signalName: String, + val signalId: String, + val color: Long, + val unit: String, + val axisPosition: AxisPosition = AxisPosition.LEFT_1, + val rawSignal: SignalDefinition? = null +) diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeViewModel.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeViewModel.kt new file mode 100644 index 0000000..99f64ca --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeViewModel.kt @@ -0,0 +1,434 @@ +package com.dct.app.presentation.signaltree + +import androidx.lifecycle.ViewModel +import com.dct.app.core.common.DispatcherProvider +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.mdb.CompensationMapType +import com.dct.app.domain.model.mdb.MdbDatabase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * 신호 탐색기 UI 상태 + */ +data class SignalTreeUiState( + val rootNodes: List = emptyList(), + val filteredNodes: List = emptyList(), + val searchQuery: String = "", + val filterMode: SignalTreeFilterMode = SignalTreeFilterMode.ALL, + val selectedNodeId: String? = null, + val selectedSelection: TreeItemSelection? = null, + val totalSignalCount: Int = 0, + val totalConstantCount: Int = 0, + val totalMapCount: Int = 0, + val expandedNodeIds: Set = emptySet() +) + +/** + * MDB 계층 신호 트리 ViewModel + */ +class SignalTreeViewModel( + private val dispatchers: DispatcherProvider, + private val scope: CoroutineScope = CoroutineScope(dispatchers.mainImmediate + SupervisorJob()) +) : ViewModel() { + + private val _uiState = MutableStateFlow(SignalTreeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _addToChartEvents = MutableSharedFlow(extraBufferCapacity = 16) + val addToChartEvents: SharedFlow = _addToChartEvents.asSharedFlow() + + /** + * 신호 탐색기 트리 상태 완전 초기화 + */ + fun clear() { + _uiState.update { SignalTreeUiState() } + } + + /** + * MDB 데이터베이스로부터 계층형 트리 노드 생성 + */ + fun setDatabase(database: MdbDatabase) { + val expandedSet = mutableSetOf() + val roots = mutableListOf() + + var totalSignals = 0 + var totalConstants = database.constants.size + var totalMaps = database.compensationMaps.size + + // 1. 신호 그룹 계층 (VarLst / VarNif) + val groupNodes = mutableListOf() + for (group in database.groups) { + val groupNodeId = "group_${group.reclid}" + expandedSet.add(groupNodeId) // 기본 펼침 + + val signalChildren = group.signals.map { sig -> + totalSignals++ + val sigNodeId = "sig_${sig.reclid}_${sig.idx}" + SignalTreeNode( + id = sigNodeId, + name = sig.name, + type = SignalTreeNodeType.SIGNAL, + description = sig.description, + unit = sig.unit, + attr = sig.attr, + cvsA = sig.cvsA, + cvsB = sig.cvsB, + min = sig.min, + max = sig.max, + color = sig.color, + groupReclid = group.reclid, + parentId = groupNodeId, + level = 2, + rawSignal = sig + ) + } + + val groupNode = SignalTreeNode( + id = groupNodeId, + name = if (group.description.isNotBlank()) group.description else "Group ${group.reclid}", + type = SignalTreeNodeType.GROUP, + description = group.strIndex, + groupReclid = group.reclid, + parentId = "root_signals", + isExpanded = true, + children = signalChildren, + level = 1 + ) + groupNodes.add(groupNode) + } + + // 최상위 신호 카테고리 + val signalCategoryNode = SignalTreeNode( + id = "root_signals", + name = "계측 신호 (Measurement Signals)", + type = SignalTreeNodeType.ROOT_CATEGORY, + description = "MDB VarLst / VarNif", + isExpanded = true, + children = groupNodes, + level = 0 + ) + expandedSet.add("root_signals") + roots.add(signalCategoryNode) + + // 2. 보정 상수 카테고리 (ConLst) + if (database.constants.isNotEmpty()) { + val constantChildren = database.constants.map { con -> + val conNodeId = "con_${con.idx}_${con.name}" + SignalTreeNode( + id = conNodeId, + name = con.name, + type = SignalTreeNodeType.CONSTANT, + description = con.description, + attr = con.attr, + min = con.min, + max = con.max, + currentValue = con.data, + parentId = "root_constants", + level = 1, + rawConstant = con + ) + } + + val constantCategoryNode = SignalTreeNode( + id = "root_constants", + name = "제어/보정 상수 (Calibration Constants)", + type = SignalTreeNodeType.ROOT_CATEGORY, + description = "MDB ConLst", + isExpanded = true, + children = constantChildren, + level = 0 + ) + expandedSet.add("root_constants") + roots.add(constantCategoryNode) + } + + // 3. 1D/2D 보정 맵 카테고리 (Tb1DmPxDyLst / Tb1DmPyDxLst) + if (database.compensationMaps.isNotEmpty()) { + val mapChildren = database.compensationMaps.map { map -> + val mapNodeId = "map_${map.type.name}_${map.idx}_${map.yName}" + val mapType = if (map.type == CompensationMapType.TYPE_A) { + SignalTreeNodeType.MAP_TYPE_A + } else { + SignalTreeNodeType.MAP_TYPE_B + } + SignalTreeNode( + id = mapNodeId, + name = map.yName, + type = mapType, + description = map.description.ifBlank { "${map.xName} -> ${map.yName}" }, + attr = map.yAttr, + cvsA = map.cvsA, + cvsB = map.cvsB, + min = map.yMin, + max = map.yMax, + parentId = "root_maps", + level = 1, + rawMap = map + ) + } + + val mapCategoryNode = SignalTreeNode( + id = "root_maps", + name = "1D/2D 보정 맵 (Compensation Maps)", + type = SignalTreeNodeType.ROOT_CATEGORY, + description = "MDB Tb1DmPxDyLst / Tb1DmPyDxLst", + isExpanded = true, + children = mapChildren, + level = 0 + ) + expandedSet.add("root_maps") + roots.add(mapCategoryNode) + } + + _uiState.update { state -> + val updatedExpanded = if (state.expandedNodeIds.isEmpty()) expandedSet else state.expandedNodeIds + val filtered = filterTree(roots, state.searchQuery, state.filterMode, updatedExpanded) + state.copy( + rootNodes = roots, + filteredNodes = filtered, + totalSignalCount = totalSignals, + totalConstantCount = totalConstants, + totalMapCount = totalMaps, + expandedNodeIds = updatedExpanded + ) + } + } + + /** + * 검색 쿼리 변경 + */ + fun onSearchQueryChanged(query: String) { + _uiState.update { state -> + // 검색어 입력 시 매칭된 노드의 부모들을 자동으로 펼침 + val autoExpanded = if (query.isNotBlank()) { + val expanded = state.expandedNodeIds.toMutableSet() + expandMatchingPaths(state.rootNodes, query, expanded) + expanded + } else { + state.expandedNodeIds + } + val filtered = filterTree(state.rootNodes, query, state.filterMode, autoExpanded) + state.copy( + searchQuery = query, + filteredNodes = filtered, + expandedNodeIds = autoExpanded + ) + } + } + + /** + * 필터 모드 변경 (전체, 신호, 상수, 맵) + */ + fun onFilterModeChanged(mode: SignalTreeFilterMode) { + _uiState.update { state -> + val filtered = filterTree(state.rootNodes, state.searchQuery, mode, state.expandedNodeIds) + state.copy( + filterMode = mode, + filteredNodes = filtered + ) + } + } + + /** + * 노드 접기/펼치기 토글 + */ + fun toggleNodeExpansion(nodeId: String) { + _uiState.update { state -> + val newExpanded = state.expandedNodeIds.toMutableSet() + if (newExpanded.contains(nodeId)) { + newExpanded.remove(nodeId) + } else { + newExpanded.add(nodeId) + } + val filtered = filterTree(state.rootNodes, state.searchQuery, state.filterMode, newExpanded) + state.copy( + expandedNodeIds = newExpanded, + filteredNodes = filtered + ) + } + } + + /** + * 전체 노드 펼치기 + */ + fun expandAll() { + _uiState.update { state -> + val allIds = mutableSetOf() + collectAllNodeIds(state.rootNodes, allIds) + val filtered = filterTree(state.rootNodes, state.searchQuery, state.filterMode, allIds) + state.copy( + expandedNodeIds = allIds, + filteredNodes = filtered + ) + } + } + + /** + * 전체 노드 접기 + */ + fun collapseAll() { + _uiState.update { state -> + val filtered = filterTree(state.rootNodes, state.searchQuery, state.filterMode, emptySet()) + state.copy( + expandedNodeIds = emptySet(), + filteredNodes = filtered + ) + } + } + + /** + * 노드 선택 및 인스펙터 바인딩 페이로드 생성 + */ + fun selectNode(node: SignalTreeNode) { + val selection = when { + node.rawSignal != null -> TreeItemSelection.SignalSelected(node.rawSignal, node) + node.rawConstant != null -> TreeItemSelection.ConstantSelected(node.rawConstant, node) + node.rawMap != null -> TreeItemSelection.MapSelected(node.rawMap, node) + node.isGroupOrRoot -> TreeItemSelection.GroupSelected(node.name, node) + else -> null + } + + _uiState.update { state -> + state.copy( + selectedNodeId = node.id, + selectedSelection = selection + ) + } + } + + /** + * 활성 차트 윈도우에 신호 추가 및 축 바인딩 요청 + */ + fun requestAddToChart(node: SignalTreeNode, axisPosition: AxisPosition = AxisPosition.LEFT_1) { + scope.launch(dispatchers.mainImmediate) { + _addToChartEvents.emit( + AddToChartRequest( + signalName = node.name, + signalId = node.id, + color = node.color, + unit = node.unit, + axisPosition = axisPosition, + rawSignal = node.rawSignal + ) + ) + } + } + + override fun onCleared() { + super.onCleared() + scope.cancel() + } + + // ========================================================================= + // Private Helper Methods + // ========================================================================= + + private fun filterTree( + nodes: List, + query: String, + filterMode: SignalTreeFilterMode, + expandedIds: Set + ): List { + val result = mutableListOf() + + for (node in nodes) { + val isExpanded = expandedIds.contains(node.id) + + // 자식 노드 재귀 필터링 + val filteredChildren = if (node.children.isNotEmpty()) { + filterTree(node.children, query, filterMode, expandedIds) + } else { + emptyList() + } + + val matchesFilterMode = when (filterMode) { + SignalTreeFilterMode.ALL -> true + SignalTreeFilterMode.SIGNALS_ONLY -> { + node.type == SignalTreeNodeType.SIGNAL || + (node.isGroupOrRoot && node.children.any { isOrContainsSignal(it) }) + } + SignalTreeFilterMode.CONSTANTS_ONLY -> { + node.type == SignalTreeNodeType.CONSTANT || + (node.isGroupOrRoot && node.children.any { isOrContainsConstant(it) }) + } + SignalTreeFilterMode.MAPS_ONLY -> { + node.type == SignalTreeNodeType.MAP_TYPE_A || node.type == SignalTreeNodeType.MAP_TYPE_B || + (node.isGroupOrRoot && node.children.any { isOrContainsMap(it) }) + } + } + + val matchesQuery = query.isBlank() || + node.name.contains(query, ignoreCase = true) || + node.unit.contains(query, ignoreCase = true) || + node.description.contains(query, ignoreCase = true) || + filteredChildren.isNotEmpty() + + if (matchesFilterMode && matchesQuery) { + // 자식이 있는 그룹인 경우, 필터된 자식이 있거나 쿼리 매칭된 경우 포함 + if (node.children.isEmpty() || filteredChildren.isNotEmpty() || query.isBlank()) { + result.add( + node.copy( + isExpanded = isExpanded, + children = filteredChildren + ) + ) + } + } + } + + return result + } + + private fun isOrContainsSignal(node: SignalTreeNode): Boolean { + if (node.type == SignalTreeNodeType.SIGNAL) return true + return node.children.any { isOrContainsSignal(it) } + } + + private fun isOrContainsConstant(node: SignalTreeNode): Boolean { + if (node.type == SignalTreeNodeType.CONSTANT) return true + return node.children.any { isOrContainsConstant(it) } + } + + private fun isOrContainsMap(node: SignalTreeNode): Boolean { + if (node.type == SignalTreeNodeType.MAP_TYPE_A || node.type == SignalTreeNodeType.MAP_TYPE_B) return true + return node.children.any { isOrContainsMap(it) } + } + + private fun expandMatchingPaths(nodes: List, query: String, expandedIds: MutableSet): Boolean { + var anyChildMatched = false + for (node in nodes) { + val selfMatch = node.name.contains(query, ignoreCase = true) || + node.unit.contains(query, ignoreCase = true) || + node.description.contains(query, ignoreCase = true) + + val childMatch = expandMatchingPaths(node.children, query, expandedIds) + + if (selfMatch || childMatch) { + if (node.children.isNotEmpty()) { + expandedIds.add(node.id) + } + anyChildMatched = true + } + } + return anyChildMatched + } + + private fun collectAllNodeIds(nodes: List, collector: MutableSet) { + for (node in nodes) { + if (node.children.isNotEmpty()) { + collector.add(node.id) + collectAllNodeIds(node.children, collector) + } + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableCommonComponents.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableCommonComponents.kt new file mode 100644 index 0000000..5152456 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/TableCommonComponents.kt @@ -0,0 +1,706 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.core.theme.toComposeColor +import java.util.Locale +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * 텍스트의 실제 글꼴 너비(11sp 기준)를 문자 단위로 정확하게 측정하는 함수 + */ +fun measureTextVisualWidthDp(text: String): Float { + var width = 0f + for (ch in text) { + if (ch.code < 128) { + width += when (ch) { + 'i', 'l', 'I', '.', ',', ':', ';', '!', '|', '\'', '`' -> 4.0f + '1', 't', 'f', 'j', 'r', '(', ')', '[', ']', '{', '}', '/', '\\', ' ' -> 5.2f + '-', '_' -> 5.8f + 'w', 'm', 'W', 'M', '@', '%', '&', '#' -> 10.2f + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'X', 'Y', 'Z' -> 8.2f + else -> 6.8f // 0..9 digits and standard lowercase English letters + } + } else { + width += 12.0f // Hangul / CJK full-width + } + } + return width +} + +/** + * 텍스트의 글꼴 및 문자별 너비를 정밀 계산하여 가장 긴 텍스트가 잘림 없이 온전히 보이도록 열 너비를 산출하는 헬퍼 함수 + */ +fun calculateAutoFitWidth( + headerTitle: String, + items: List, + defaultWidth: Dp, + extraPadding: Dp = 16.dp, + minWidth: Dp = 45.dp, + maxWidth: Dp = 600.dp +): Dp { + val validItems = items.filter { it.isNotBlank() } + val allTexts = if (validItems.isEmpty()) listOf(headerTitle) else validItems + headerTitle + // 헤더는 Bold이므로 1.15배 가중치 적용 + val maxVisualWidth = allTexts.maxOfOrNull { text -> + val visualW = measureTextVisualWidthDp(text) + if (text == headerTitle) visualW * 1.15f else visualW + } ?: (measureTextVisualWidthDp(headerTitle) * 1.15f) + val exactWidth = (maxVisualWidth + extraPadding.value).dp + return exactWidth.coerceIn(minWidth, maxWidth) +} + +/** + * 더블 탭으로 열 너비 자동 맞춤(Auto-Fit)을 지원하는 공통 테이블 헤더 셀 + */ +@Composable +fun ResizableTableHeaderCell( + title: String, + width: Dp, + onDoubleTap: () -> Unit, + modifier: Modifier = Modifier, + textColor: Color = Color(0xFF212529), + fontWeight: FontWeight = FontWeight.Bold, + showDivider: Boolean = true, + dividerColor: Color = Color(0xFFCED4DA), + contentAlignment: Alignment = Alignment.CenterStart, + onWidthChange: ((Dp) -> Unit)? = null, + onClick: (() -> Unit)? = null, + textStyle: androidx.compose.ui.text.TextStyle = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle(includeFontPadding = false) + ) +) { + val density = androidx.compose.ui.platform.LocalDensity.current.density + val currentWidth by rememberUpdatedState(width) + val currentResize by rememberUpdatedState(onWidthChange) + val currentClick by rememberUpdatedState(onClick) + val currentDoubleTap by rememberUpdatedState(onDoubleTap) + Row( + modifier = modifier + .width(width) + .fillMaxHeight() + .pointerInput(title) { + detectTapGestures( + onDoubleTap = { currentDoubleTap() }, + onTap = { currentClick?.invoke() } + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 4.dp), + contentAlignment = contentAlignment + ) { + Text( + text = title, + fontSize = 11.sp, + fontWeight = fontWeight, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = textStyle + ) + } + if (showDivider || onWidthChange != null) { + Box( + modifier = Modifier.width(if (onWidthChange != null) 12.dp else 1.dp).fillMaxHeight() + .pointerInput(title, onWidthChange != null) { + var dragWidth = 0f + detectDragGestures( + onDragStart = { dragWidth = currentWidth.value }, + onDrag = { change, delta -> + if (currentResize != null) { + change.consume() + dragWidth = (dragWidth + delta.x / density).coerceIn(32f, 1200f) + currentResize?.invoke(dragWidth.dp) + } + } + ) + }, + contentAlignment = Alignment.CenterEnd + ) { Box(Modifier.width(1.dp).fillMaxHeight().background(dividerColor)) } + } + } +} + +/** + * Windows 클래식 서브헤더 (Search 검색창 + 우측 끝 Expand/Collapse 버튼) + */ +@Composable +fun TableSubHeader( + searchQuery: String, + onSearchChange: (String) -> Unit, + showExpand: Boolean = true, + isExpanded: Boolean = false, + onExpandClick: () -> Unit = {}, + searchEnabled: Boolean = true +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF0F0F0)) + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 검색 필드 (좌측) + BasicTextField( + value = searchQuery, + onValueChange = onSearchChange, + enabled = searchEnabled, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 11.sp, + color = Color(0xFF202124), + fontFamily = FontFamily.SansSerif, + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .width(150.dp) + .height(26.dp) + .background(Color.White, RoundedCornerShape(2.dp)) + .border(1.dp, Color(0xFFB0B8C0), RoundedCornerShape(2.dp)) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.CenterStart + ) { + if (searchQuery.isEmpty()) { + Text( + text = "Search...", + fontSize = 11.sp, + color = Color(0xFF90A4AE), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + innerTextField() + } + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Expand / Collapse 토글 버튼 (우측 끝 통일) + if (showExpand) { + Surface( + color = Color(0xFFF8F9FA), + shape = RoundedCornerShape(2.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFB0B8C0)), + modifier = Modifier + .height(26.dp) + .clickable { onExpandClick() } + ) { + Box( + modifier = Modifier.padding(horizontal = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (isExpanded) "Collapse" else "Expand", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF202124), + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } + } + } +} + +/** + * 6자리 Hex 컬러 문자열 및 배경 박스 렌더러 (예: 7B4560, FF0000, 00FF00) + */ +@Composable +fun TableColorCell( + colorValue: Long, + modifier: Modifier = Modifier +) { + val composeColor = colorValue.toComposeColor() + val hexStr = remember(colorValue) { + val rgb = (colorValue and 0xFFFFFFL) + String.format(Locale.US, "%06X", rgb) + } + val textColor = if (composeColor.luminance() > 0.5f) Color.Black else Color.White + + Box( + modifier = modifier + .fillMaxHeight() + .background(composeColor) + .padding(horizontal = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = hexStr, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = textColor, + maxLines = 1, + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } +} + +/** + * 신호 테이블의 Color 셀을 눌러 PC와 동일한 신호 고유 색상을 편집한다. + * 입력 UI는 일반적인 RGB(#RRGGBB)를 사용하고 저장 시 DCT의 COLORREF(0x00BBGGRR)로 변환한다. + */ +@Composable +fun EditableTableColorCell( + colorValue: Long, + onColorChanged: (Long) -> Unit, + modifier: Modifier = Modifier +) { + var isDialogOpen by remember { mutableStateOf(false) } + + TableColorCell( + colorValue = colorValue, + modifier = modifier.clickable { isDialogOpen = true } + ) + + if (isDialogOpen) { + SignalColorPickerDialog( + initialColor = colorValue, + onConfirm = { color -> + onColorChanged(color) + isDialogOpen = false + }, + onDismiss = { isDialogOpen = false } + ) + } +} + +@Composable +private fun SignalColorPickerDialog( + initialColor: Long, + onConfirm: (Long) -> Unit, + onDismiss: () -> Unit +) { + val initialHsv = remember(initialColor) { colorRefToHsv(initialColor) } + var currentHue by remember(initialColor) { mutableFloatStateOf(initialHsv[0]) } + var currentSat by remember(initialColor) { mutableFloatStateOf(initialHsv[1]) } + var currentVal by remember(initialColor) { mutableFloatStateOf(initialHsv[2]) } + + var selectedColor by remember(initialColor) { mutableLongStateOf(initialColor and 0xFFFFFFL) } + var colorHex by remember(initialColor) { mutableStateOf(colorRefToRawHex(initialColor)) } + val parsedColor = remember(colorHex) { rawHexToColorRefOrNull(colorHex) } + + fun updateFromHsv(h: Float, s: Float, v: Float) { + currentHue = h + currentSat = s + currentVal = v + val newColorRef = hsvToColorRef(floatArrayOf(h, s, v)) + selectedColor = newColorRef + colorHex = colorRefToRawHex(newColorRef) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Signal Color", fontSize = 16.sp, fontWeight = FontWeight.Bold) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + // 1. 상단 색상 미리보기 및 Hex 입력 + OutlinedTextField( + value = colorHex, + onValueChange = { input -> + val filtered = input.removePrefix("0x").removePrefix("0X").removePrefix("#") + .filter { it.isDigit() || it.uppercaseChar() in 'A'..'F' } + .take(6) + .uppercase(Locale.ROOT) + colorHex = filtered + val parsed = rawHexToColorRefOrNull(filtered) + if (parsed != null) { + selectedColor = parsed + val hsv = colorRefToHsv(parsed) + currentHue = hsv[0] + currentSat = hsv[1] + currentVal = hsv[2] + } + }, + label = { Text("Color Hex (%06X)") }, + leadingIcon = { + Box( + modifier = Modifier + .size(24.dp) + .background(selectedColor.toComposeColor(), RoundedCornerShape(4.dp)) + .border(1.dp, Color(0xFF94A3B8), RoundedCornerShape(4.dp)) + ) + }, + isError = parsedColor == null, + supportingText = if (parsedColor == null) { + { Text("6자리 Hex 값을 입력하세요 (예: 00FFFF).") } + } else null, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // 2. 360° 원형 컬러 휠 (Color Wheel) + CircularColorWheel( + hue = currentHue, + saturation = currentSat, + value = currentVal, + onColorChanged = { h, s -> + updateFromHsv(h, s, currentVal) + }, + modifier = Modifier.size(190.dp) + ) + + // 3. 밝기(Brightness / Value) 슬라이더 + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Brightness", fontSize = 12.sp, color = Color(0xFF475569), fontWeight = FontWeight.SemiBold) + Text("${(currentVal * 100).toInt()}%", fontSize = 12.sp, color = Color(0xFF64748B), fontFamily = FontFamily.Monospace) + } + Slider( + value = currentVal, + onValueChange = { v -> + updateFromHsv(currentHue, currentSat, v) + }, + valueRange = 0f..1f, + colors = SliderDefaults.colors( + thumbColor = Color(0xFF2563EB), + activeTrackColor = Color(0xFF2563EB), + inactiveTrackColor = Color(0xFFE2E8F0) + ) + ) + } + } + }, + confirmButton = { + TextButton( + enabled = parsedColor != null, + onClick = { parsedColor?.let(onConfirm) } + ) { Text("Apply", fontWeight = FontWeight.Bold) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +/** + * 360° 원형 HSV 컬러 휠 컴포넌트 + */ +@Composable +private fun CircularColorWheel( + hue: Float, + saturation: Float, + value: Float, + onColorChanged: (hue: Float, saturation: Float) -> Unit, + modifier: Modifier = Modifier +) { + val wheelColors = remember { + listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + } + + Canvas( + modifier = modifier + .pointerInput(Unit) { + fun processOffset(offset: Offset) { + val cx = size.width / 2f + val cy = size.height / 2f + val dx = offset.x - cx + val dy = offset.y - cy + val radius = minOf(cx, cy) + if (radius <= 0f) return + + val dist = sqrt(dx * dx + dy * dy) + val sat = (dist / radius).coerceIn(0f, 1f) + val angleRad = atan2(dy, dx) + val angleDeg = Math.toDegrees(angleRad.toDouble()).toFloat() + val h = (angleDeg + 360f) % 360f + + onColorChanged(h, sat) + } + + detectTapGestures { offset -> processOffset(offset) } + } + .pointerInput(Unit) { + fun processOffset(offset: Offset) { + val cx = size.width / 2f + val cy = size.height / 2f + val dx = offset.x - cx + val dy = offset.y - cy + val radius = minOf(cx, cy) + if (radius <= 0f) return + + val dist = sqrt(dx * dx + dy * dy) + val sat = (dist / radius).coerceIn(0f, 1f) + val angleRad = atan2(dy, dx) + val angleDeg = Math.toDegrees(angleRad.toDouble()).toFloat() + val h = (angleDeg + 360f) % 360f + + onColorChanged(h, sat) + } + + detectDragGestures { change, _ -> + change.consume() + processOffset(change.position) + } + } + ) { + val cx = size.width / 2f + val cy = size.height / 2f + val radius = minOf(cx, cy) + + // 1. 360도 색상 (Sweep Gradient) + drawCircle( + brush = Brush.sweepGradient(wheelColors, center = Offset(cx, cy)), + radius = radius, + center = Offset(cx, cy) + ) + + // 2. 중심 채도 흰색 감쇠 (Radial Gradient: Center White -> Outer Transparent) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(Color.White, Color.Transparent), + center = Offset(cx, cy), + radius = radius + ), + radius = radius, + center = Offset(cx, cy) + ) + + // 3. 밝기(Value)에 따른 블랙 오버레이 + if (value < 1f) { + drawCircle( + color = Color.Black.copy(alpha = 1f - value), + radius = radius, + center = Offset(cx, cy) + ) + } + + // 4. 외곽 테두리 + drawCircle( + color = Color(0xFFCED4DA), + radius = radius, + center = Offset(cx, cy), + style = androidx.compose.ui.graphics.drawscope.Stroke(width = 1.5.dp.toPx()) + ) + + // 5. 현재 선택된 색상 위치 선택 핸들(Indicator) + val selectedRad = Math.toRadians(hue.toDouble()) + val selectedDist = saturation * radius + val handleX = cx + (selectedDist * cos(selectedRad)).toFloat() + val handleY = cy + (selectedDist * sin(selectedRad)).toFloat() + + val currentColorInt = android.graphics.Color.HSVToColor(floatArrayOf(hue, saturation, value)) + val currentComposeColor = Color(currentColorInt) + + // 핸들 외곽선 (블랙 + 화이트) + drawCircle( + color = Color.Black.copy(alpha = 0.4f), + radius = 10.dp.toPx(), + center = Offset(handleX, handleY) + ) + drawCircle( + color = Color.White, + radius = 8.dp.toPx(), + center = Offset(handleX, handleY) + ) + // 핸들 내부 색상 + drawCircle( + color = currentComposeColor, + radius = 6.dp.toPx(), + center = Offset(handleX, handleY) + ) + } +} + +private fun colorRefToHsv(colorRef: Long): FloatArray { + val value = colorRef and 0xFFFFFFL + val r = (value and 0xFFL).toInt() + val g = ((value shr 8) and 0xFFL).toInt() + val b = ((value shr 16) and 0xFFL).toInt() + val hsv = FloatArray(3) + android.graphics.Color.RGBToHSV(r, g, b, hsv) + return hsv +} + +private fun hsvToColorRef(hsv: FloatArray): Long { + val colorInt = android.graphics.Color.HSVToColor(hsv) + val r = android.graphics.Color.red(colorInt) + val g = android.graphics.Color.green(colorInt) + val b = android.graphics.Color.blue(colorInt) + return (r.toLong() and 0xFFL) or ((g.toLong() and 0xFFL) shl 8) or ((b.toLong() and 0xFFL) shl 16) +} + +private fun colorRefToRawHex(colorRef: Long): String { + val value = colorRef and 0xFFFFFFL + return String.format(Locale.US, "%06X", value) +} + +private fun rawHexToColorRefOrNull(input: String): Long? { + val normalized = input.trim().removePrefix("0x").removePrefix("0X").removePrefix("#") + if (!normalized.matches(Regex("[0-9A-Fa-f]{1,6}"))) return null + return normalized.toLongOrNull(16)?.and(0xFFFFFFL) +} + +@Composable +fun TableEditableDescriptionCell( + description: String, + onDescriptionChanged: (String) -> Unit, + modifier: Modifier = Modifier, + commitOnChange: Boolean = false, + enabled: Boolean = true +) { + var isEditing by remember { mutableStateOf(false) } + var textValue by remember(description) { mutableStateOf(description) } + var hasGainedFocus by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + DisposableEffect(isEditing) { + onDispose { + if (!commitOnChange && isEditing && textValue != description) { + onDescriptionChanged(textValue) + } + } + } + + LaunchedEffect(isEditing) { + if (isEditing) { + hasGainedFocus = false + try { + focusRequester.requestFocus() + keyboardController?.show() + } catch (_: Exception) {} + } + } + + fun finishEditing() { + if (isEditing) { + isEditing = false + hasGainedFocus = false + focusManager.clearFocus() + keyboardController?.hide() + if (textValue != description) { + onDescriptionChanged(textValue) + } + } + } + + Box( + modifier = modifier + .fillMaxHeight() + .clickable(enabled = enabled) { + if (!isEditing) { + textValue = description + isEditing = true + } + } + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart + ) { + if (isEditing) { + BasicTextField( + enabled = enabled, + value = textValue, + onValueChange = { + textValue = it + if (commitOnChange) onDescriptionChanged(it) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { finishEditing() }), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 10.sp, + color = Color(0xFF111827), + fontFamily = FontFamily.SansSerif, + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused) { + hasGainedFocus = true + } else if (hasGainedFocus && isEditing) { + finishEditing() + } + } + ) + } else { + Text( + text = description, + fontSize = 10.sp, + color = Color(0xFF212529), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = androidx.compose.ui.text.TextStyle( + platformStyle = androidx.compose.ui.text.PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowUiState.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowUiState.kt new file mode 100644 index 0000000..f88565d --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowUiState.kt @@ -0,0 +1,60 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.runtime.* +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** Small, file-safe window preferences. No input drafts, device values or live buffers. */ +class WindowUiStore(initial: Map, private val changed: (Map) -> Unit) { + var values by mutableStateOf(initial) + private set + fun put(key: String, value: String?) { + val next = if (value == null) values - key else values + (key to value) + if (next != values) { values = next; changed(next) } + } +} +private val LocalWindowUiStore = staticCompositionLocalOf { null } + +@Composable +fun WindowUiStateProvider(id: String, initial: Map, onChange: (Map) -> Unit, content: @Composable () -> Unit) { + val latest by rememberUpdatedState(onChange) + val store = remember(id) { WindowUiStore(initial) { latest(it) } } + CompositionLocalProvider(LocalWindowUiStore provides store, content = content) +} + +@Composable +private fun rememberWindowValue(key: String, initial: T, encode: (T) -> String?, decode: (String?) -> T): MutableState { + val store = LocalWindowUiStore.current + return remember(store, key) { + if (store == null) mutableStateOf(initial) else object : MutableState { + override var value: T + get() = decode(store.values[key]) + set(value) { store.put(key, encode(value)) } + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } + } + } +} +@Composable fun rememberWindowString(key: String, initial: String = "") = + rememberWindowValue(key, initial, { it }, { it ?: initial }) +@Composable fun rememberWindowNullableString(key: String) = + rememberWindowValue(key, null, { it }, { it }) +@Composable fun rememberWindowBoolean(key: String, initial: Boolean = false) = + rememberWindowValue(key, initial, { it.toString() }, { it?.toBooleanStrictOrNull() ?: initial }) +@Composable fun rememberWindowInt(key: String, initial: Int = 0) = + rememberWindowValue(key, initial, { it.toString() }, { it?.toIntOrNull() ?: initial }) +@Composable fun rememberWindowChecked(key: String): MutableState> = + rememberWindowValue(key, emptySet(), { it.sorted().joinToString(",") }, { + it?.split(",")?.mapNotNull(String::toLongOrNull)?.toSet() ?: emptySet() + }) + +class WindowColumnWidths(private val store: WindowUiStore) { + operator fun get(key: String): Dp? = store.values["width:$key"]?.toFloatOrNull()?.takeIf { it.isFinite() }?.coerceIn(32f, 1200f)?.dp + operator fun set(key: String, value: Dp) { if (value.value.isFinite()) store.put("width:$key", value.value.coerceIn(32f, 1200f).toString()) } + fun containsKey(key: String) = get(key) != null + fun remove(key: String) = store.put("width:$key", null) +} +@Composable fun rememberWindowColumnWidths(): WindowColumnWidths { + val parent = LocalWindowUiStore.current + return remember(parent) { WindowColumnWidths(parent ?: WindowUiStore(emptyMap()) {}) } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowsClassicTreeView.kt b/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowsClassicTreeView.kt new file mode 100644 index 0000000..7f9f6c4 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowsClassicTreeView.kt @@ -0,0 +1,498 @@ +package com.dct.app.presentation.signaltree + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dct.app.domain.model.mdb.CompensationMap +import com.dct.app.domain.model.mdb.MdbConstant +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition + +/** + * 트리 노드 데이터 모델 + */ +data class ClassicTreeNode( + val id: String, + val label: String, + val isFolder: Boolean, + val isLeafSignal: Boolean = false, + val children: List = emptyList(), + val level: Int = 0, + val rawSignal: SignalDefinition? = null, + val rawConstant: MdbConstant? = null, + val rawMap: CompensationMap? = null, + val isCan: Boolean = false +) + +/** + * Windows 스타일 클래식 신호 트리뷰 + */ +@Composable +fun WindowsClassicSignalTreeView( + mdbDatabase: MdbDatabase?, + canSignals: List, + a2lSignals: List = emptyList(), + selectedNodeId: String?, + onSignalClick: (SignalDefinition, Boolean) -> Unit, + onCategoryClick: (String) -> Unit = {}, + onSignalDoubleClick: (SignalDefinition, Boolean) -> Unit = onSignalClick, + searchQuery: String = "", + modifier: Modifier = Modifier +) { + // 펼침 상태 관리 (CAN DBC, 그리고 파일 로드 시 MDB 또는 A2L 자동 펼침) + val expandedNodes = remember(mdbDatabase != null, a2lSignals.isNotEmpty()) { + mutableStateMapOf( + "root_can_dbc" to true, + "root_mdb" to (mdbDatabase != null), + "root_a2l" to a2lSignals.isNotEmpty(), + "root_formula" to false, + "root_sensor_module" to false + ) + } + + // 트리 계층 구성 + val rootNodes = remember(mdbDatabase, canSignals, a2lSignals, searchQuery) { + buildClassicTree(mdbDatabase, canSignals, a2lSignals, searchQuery) + } + + // 펼쳐진 노드들을 1차원 목록으로 평탄화 (Dotted Line 연결 계산 포함) + val flattenedList = remember(rootNodes, expandedNodes.toMap()) { + val list = mutableListOf() + fun traverse(node: ClassicTreeNode, level: Int, isLastChild: Boolean, parentLines: List) { + val isExpanded = expandedNodes[node.id] ?: false + list.add( + FlattenedTreeItem( + node = node, + level = level, + isLastChild = isLastChild, + parentLines = parentLines, + hasChildren = node.children.isNotEmpty(), + isExpanded = isExpanded + ) + ) + if (isExpanded && node.children.isNotEmpty()) { + node.children.forEachIndexed { index, child -> + val childIsLast = index == node.children.size - 1 + traverse(child, level + 1, childIsLast, parentLines + listOf(!isLastChild)) + } + } + } + + rootNodes.forEachIndexed { index, root -> + val isLast = index == rootNodes.size - 1 + traverse(root, 0, isLast, emptyList()) + } + list + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(Color.White) + ) { + items(flattenedList, key = { it.node.id }) { item -> + ClassicTreeRowItem( + item = item, + isSelected = selectedNodeId == item.node.id, + onToggleExpand = { + val cur = expandedNodes[item.node.id] ?: false + expandedNodes[item.node.id] = !cur + }, + onClick = { + if (item.hasChildren) { + val cur = expandedNodes[item.node.id] ?: false + expandedNodes[item.node.id] = !cur + } + onCategoryClick(item.node.id) + } + ) + } + } +} + +/** + * 평탄화된 트리 아이템 (렌더링용) + */ +private data class FlattenedTreeItem( + val node: ClassicTreeNode, + val level: Int, + val isLastChild: Boolean, + val parentLines: List, + val hasChildren: Boolean, + val isExpanded: Boolean +) + +/** + * 개별 트리 행 아이템 + */ +@Composable +private fun ClassicTreeRowItem( + item: FlattenedTreeItem, + isSelected: Boolean, + onToggleExpand: () -> Unit, + onClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(22.dp) + .background(if (isSelected) Color(0xFFCCE8FF) else Color.Transparent) + .clickable { onClick() } + ) { + // 1. 도트 계층 트리 라인 + 토글 박스 영역 (너비 = level * 16dp + 16dp) + val treeDepthWidth = (item.level * 16 + 18).dp + Box( + modifier = Modifier + .width(treeDepthWidth) + .fillMaxHeight() + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val stepPx = 16.dp.toPx() + val centerY = size.height / 2f + val dashEffect = PathEffect.dashPathEffect(floatArrayOf(1.5f, 2.5f), 0f) + + // 상위 부모 계층들의 수직 연결선 + item.parentLines.forEachIndexed { lvl, hasNext -> + if (hasNext) { + val x = lvl * stepPx + 9.dp.toPx() + drawLine( + color = Color(0xFF888888), + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = 1f, + pathEffect = dashEffect + ) + } + } + + // 현재 노드의 수직 및 수평 도트선 + val currentX = item.level * stepPx + 9.dp.toPx() + val lineEndY = if (item.isLastChild) centerY else size.height + drawLine( + color = Color(0xFF888888), + start = Offset(currentX, 0f), + end = Offset(currentX, lineEndY), + strokeWidth = 1f, + pathEffect = dashEffect + ) + drawLine( + color = Color(0xFF888888), + start = Offset(currentX, centerY), + end = Offset(currentX + 7.dp.toPx(), centerY), + strokeWidth = 1f, + pathEffect = dashEffect + ) + } + + // [+] / [-] 토글 박스 (자식이 있는 폴더/카테고리인 경우) + if (item.hasChildren) { + val boxOffset = (item.level * 16 + 4).dp + Box( + modifier = Modifier + .offset(x = boxOffset) + .align(Alignment.CenterStart) + .size(9.dp) + .background(Color.White) + .border(1.dp, Color(0xFF808080)) + .clickable { onToggleExpand() }, + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.size(5.dp)) { + // 가로 선 (-) + drawLine( + color = Color(0xFF222222), + start = Offset(0f, size.height / 2f), + end = Offset(size.width, size.height / 2f), + strokeWidth = 1.2f + ) + // 세로 선 (+) if not expanded + if (!item.isExpanded) { + drawLine( + color = Color(0xFF222222), + start = Offset(size.width / 2f, 0f), + end = Offset(size.width / 2f, size.height), + strokeWidth = 1.2f + ) + } + } + } + } + } + + Spacer(modifier = Modifier.width(2.dp)) + + // 2. 아이콘 (폴더 vs 시그널) + if (item.node.isFolder) { + WindowsFolderIcon() + } else { + WindowsSignalPointingHandIcon() + } + + Spacer(modifier = Modifier.width(6.dp)) + + // 3. 노드 이름 텍스트 + Text( + text = item.node.label, + fontSize = 12.sp, + fontFamily = FontFamily.SansSerif, + fontWeight = if (item.node.isFolder && item.level == 0) FontWeight.Medium else FontWeight.Normal, + color = Color(0xFF191C1E), + maxLines = 1 + ) + } +} + +/** + * Windows 스타일 클래식 노란색 폴더 아이콘 + */ +@Composable +private fun WindowsFolderIcon() { + Canvas(modifier = Modifier.size(16.dp)) { + val w = size.width + val h = size.height + + // 뒤쪽 폴더 탭 (Dark Yellow) + val tabPath = Path().apply { + moveTo(1.dp.toPx(), 3.dp.toPx()) + lineTo(6.dp.toPx(), 3.dp.toPx()) + lineTo(8.dp.toPx(), 5.dp.toPx()) + lineTo(w - 2.dp.toPx(), 5.dp.toPx()) + lineTo(w - 2.dp.toPx(), 13.dp.toPx()) + lineTo(1.dp.toPx(), 13.dp.toPx()) + close() + } + drawPath(tabPath, color = Color(0xFFE5A93C)) + + // 앞쪽 폴더 본체 (Bright Yellow) + val frontPath = Path().apply { + moveTo(1.dp.toPx(), 6.dp.toPx()) + lineTo(w - 1.dp.toPx(), 6.dp.toPx()) + lineTo(w - 2.5.dp.toPx(), 14.dp.toPx()) + lineTo(0.dp.toPx(), 14.dp.toPx()) + close() + } + drawPath(frontPath, color = Color(0xFFFFD54F)) + drawPath(frontPath, color = Color(0xFFD49A24), style = Stroke(width = 0.8f)) + } +} + +/** + * Windows 스타일 가리키는 손 + 문서 시그널 아이콘 + */ +@Composable +private fun WindowsSignalPointingHandIcon() { + Canvas(modifier = Modifier.size(16.dp)) { + val w = size.width + val h = size.height + + // 뒤쪽 흰색 문서 + val docPath = Path().apply { + moveTo(2.dp.toPx(), 2.dp.toPx()) + lineTo(w - 3.dp.toPx(), 2.dp.toPx()) + lineTo(w - 3.dp.toPx(), h - 2.dp.toPx()) + lineTo(2.dp.toPx(), h - 2.dp.toPx()) + close() + } + drawPath(docPath, color = Color(0xFFFFFFFF)) + drawPath(docPath, color = Color(0xFF90A4AE), style = Stroke(width = 0.8f)) + + // 문서 안의 가로 줄무늬 + drawLine( + color = Color(0xFFCFD8DC), + start = Offset(4.dp.toPx(), 5.dp.toPx()), + end = Offset(w - 5.dp.toPx(), 5.dp.toPx()), + strokeWidth = 1f + ) + drawLine( + color = Color(0xFFCFD8DC), + start = Offset(4.dp.toPx(), 8.dp.toPx()), + end = Offset(w - 5.dp.toPx(), 8.dp.toPx()), + strokeWidth = 1f + ) + + // 가리키는 손목 (초록색 소매) + drawRect( + color = Color(0xFF2E7D32), + topLeft = Offset(1.dp.toPx(), 7.dp.toPx()), + size = androidx.compose.ui.geometry.Size(3.5.dp.toPx(), 4.dp.toPx()) + ) + + // 손가락 (피부색 살구색 + 오른쪽 가리킴) + val handPath = Path().apply { + moveTo(4.5.dp.toPx(), 7.5.dp.toPx()) + lineTo(12.dp.toPx(), 7.5.dp.toPx()) + lineTo(12.dp.toPx(), 9.5.dp.toPx()) + lineTo(7.dp.toPx(), 11.5.dp.toPx()) + lineTo(4.5.dp.toPx(), 10.5.dp.toPx()) + close() + } + drawPath(handPath, color = Color(0xFFFFCC80)) + drawPath(handPath, color = Color(0xFFE65100), style = Stroke(width = 0.6f)) + } +} + +/** + * Figma 및 첨부 레퍼런스 이미지 기반 트리 노드 생성 로직 + * 5대 고정 루트: CAN DBC, Formula, MDB, A2L, Sensor Module + */ +private fun buildClassicTree( + database: MdbDatabase?, + canSignals: List, + a2lSignals: List = emptyList(), + searchQuery: String +): List { + val roots = mutableListOf() + + // 1. CAN DBC (상시 노출, CAN Signal 자식 포함) + roots.add( + ClassicTreeNode( + id = "root_can_dbc", + label = "CAN DBC", + isFolder = true, + children = listOf( + ClassicTreeNode( + id = "category_can_signal", + label = "CAN Signal", + isFolder = false, + isLeafSignal = true, + level = 1 + ) + ), + level = 0 + ) + ) + + // 2. Formula (상시 노출, 단일 폴더) + roots.add( + ClassicTreeNode( + id = "root_formula", + label = "Formula", + isFolder = true, + children = emptyList(), + level = 0 + ) + ) + + // 3. MDB (상시 노출, MDB 로드 시 자식 4개 노출) + val mdbCategoryChildren = mutableListOf() + if (database != null) { + mdbCategoryChildren.add( + ClassicTreeNode( + id = "category_mdb_signal", + label = "MDB Signal", + isFolder = false, + isLeafSignal = true, + level = 1 + ) + ) + mdbCategoryChildren.add( + ClassicTreeNode( + id = "category_constant_signal", + label = "Constant Signal", + isFolder = false, + isLeafSignal = true, + level = 1 + ) + ) + mdbCategoryChildren.add( + ClassicTreeNode( + id = "category_compensation_type_a", + label = "Compensation Type A", + isFolder = false, + isLeafSignal = true, + level = 1 + ) + ) + mdbCategoryChildren.add( + ClassicTreeNode( + id = "category_compensation_type_b", + label = "Compensation Type B", + isFolder = false, + isLeafSignal = true, + level = 1 + ) + ) + } + roots.add( + ClassicTreeNode( + id = "root_mdb", + label = "MDB", + isFolder = true, + children = mdbCategoryChildren, + level = 0 + ) + ) + + // 4. A2L (상시 노출, A2L 로드 시 자식 3개 노출: Measurements, Characteristics, CharacteristicsArray) + val a2lCategoryChildren = mutableListOf() + if (a2lSignals.isNotEmpty()) { + a2lCategoryChildren.add( + ClassicTreeNode( + id = "category_a2l_measurements", + label = "Measurements", + isFolder = false, + isLeafSignal = true, + level = 1 + ) + ) + a2lCategoryChildren.add( + ClassicTreeNode( + id = "category_a2l_characteristics", + label = "Characteristics", + isFolder = false, + isLeafSignal = true, + level = 1 + ) + ) + a2lCategoryChildren.add( + ClassicTreeNode( + id = "category_a2l_characteristics_array", + label = "CharacteristicsArray", + isFolder = false, + isLeafSignal = true, + level = 1 + ) + ) + } + roots.add( + ClassicTreeNode( + id = "root_a2l", + label = "A2L", + isFolder = true, + children = a2lCategoryChildren, + level = 0 + ) + ) + + // 5. Sensor Module (상시 노출, 단일 폴더) + roots.add( + ClassicTreeNode( + id = "root_sensor_module", + label = "Sensor Module", + isFolder = true, + children = emptyList(), + level = 0 + ) + ) + + return roots +} diff --git a/Android/app/src/main/java/javax/script/Bindings.java b/Android/app/src/main/java/javax/script/Bindings.java new file mode 100644 index 0000000..35c3d3f --- /dev/null +++ b/Android/app/src/main/java/javax/script/Bindings.java @@ -0,0 +1,20 @@ +package javax.script; + +import java.util.Map; + +public interface Bindings extends Map { + @Override + public Object put(String name, Object value); + + @Override + public void putAll(Map toMerge); + + @Override + public boolean containsKey(Object key); + + @Override + public Object get(Object key); + + @Override + public Object remove(Object key); +} diff --git a/Android/app/src/main/java/javax/script/ScriptContext.java b/Android/app/src/main/java/javax/script/ScriptContext.java new file mode 100644 index 0000000..95db3be --- /dev/null +++ b/Android/app/src/main/java/javax/script/ScriptContext.java @@ -0,0 +1,25 @@ +package javax.script; + +import java.io.Reader; +import java.io.Writer; +import java.util.List; + +public interface ScriptContext { + public static final int ENGINE_SCOPE = 100; + public static final int GLOBAL_SCOPE = 200; + + public void setBindings(Bindings bindings, int scope); + public Bindings getBindings(int scope); + public void setAttribute(String name, Object value, int scope); + public Object getAttribute(String name, int scope); + public Object removeAttribute(String name, int scope); + public Object getAttribute(String name); + public int getAttributesScope(String name); + public Writer getWriter(); + public Writer getErrorWriter(); + public void setWriter(Writer writer); + public void setErrorWriter(Writer writer); + public Reader getReader(); + public void setReader(Reader reader); + public List getScopes(); +} diff --git a/Android/app/src/main/java/javax/script/ScriptEngine.java b/Android/app/src/main/java/javax/script/ScriptEngine.java new file mode 100644 index 0000000..3e64912 --- /dev/null +++ b/Android/app/src/main/java/javax/script/ScriptEngine.java @@ -0,0 +1,28 @@ +package javax.script; + +import java.io.Reader; + +public interface ScriptEngine { + public static final String ARGV = "javax.script.argv"; + public static final String FILENAME = "javax.script.filename"; + public static final String ENGINE = "javax.script.engine"; + public static final String ENGINE_VERSION = "javax.script.engine_version"; + public static final String NAME = "javax.script.name"; + public static final String LANGUAGE = "javax.script.language"; + public static final String LANGUAGE_VERSION = "javax.script.language_version"; + + public Object eval(String script, ScriptContext context) throws ScriptException; + public Object eval(Reader reader, ScriptContext context) throws ScriptException; + public Object eval(String script) throws ScriptException; + public Object eval(Reader reader) throws ScriptException; + public Object eval(String script, Bindings n) throws ScriptException; + public Object eval(Reader reader, Bindings n) throws ScriptException; + public void put(String key, Object value); + public Object get(String key); + public Bindings getBindings(int scope); + public void setBindings(Bindings bindings, int scope); + public Bindings createBindings(); + public ScriptContext getContext(); + public void setContext(ScriptContext context); + public ScriptEngineFactory getFactory(); +} diff --git a/Android/app/src/main/java/javax/script/ScriptEngineFactory.java b/Android/app/src/main/java/javax/script/ScriptEngineFactory.java new file mode 100644 index 0000000..f4b0d0b --- /dev/null +++ b/Android/app/src/main/java/javax/script/ScriptEngineFactory.java @@ -0,0 +1,18 @@ +package javax.script; + +import java.util.List; + +public interface ScriptEngineFactory { + public String getEngineName(); + public String getEngineVersion(); + public List getExtensions(); + public List getMimeTypes(); + public List getNames(); + public String getLanguageName(); + public String getLanguageVersion(); + public Object getParameter(String key); + public String getMethodCallSyntax(String obj, String m, String... args); + public String getOutputStatement(String toDisplay); + public String getProgram(String... statements); + public ScriptEngine getScriptEngine(); +} diff --git a/Android/app/src/main/java/javax/script/ScriptEngineManager.java b/Android/app/src/main/java/javax/script/ScriptEngineManager.java new file mode 100644 index 0000000..51bfc4a --- /dev/null +++ b/Android/app/src/main/java/javax/script/ScriptEngineManager.java @@ -0,0 +1,67 @@ +package javax.script; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +public class ScriptEngineManager { + private Bindings globalScope; + private final HashMap nameAssociations = new HashMap<>(); + + public ScriptEngineManager() { + this.globalScope = new SimpleBindings(); + } + + public ScriptEngineManager(ClassLoader loader) { + this.globalScope = new SimpleBindings(); + } + + public Bindings getBindings() { + return globalScope; + } + + public void setBindings(Bindings bindings) { + if (bindings == null) { + throw new IllegalArgumentException("Global scope cannot be null."); + } + this.globalScope = bindings; + } + + public void put(String key, Object value) { + globalScope.put(key, value); + } + + public Object get(String key) { + return globalScope.get(key); + } + + public ScriptEngine getEngineByName(String shortName) { + return null; + } + + public ScriptEngine getEngineByExtension(String extension) { + return null; + } + + public ScriptEngine getEngineByMimeType(String mimeType) { + return null; + } + + public List getEngineFactories() { + return Collections.unmodifiableList(new ArrayList<>(nameAssociations.values())); + } + + public void registerEngineName(String name, ScriptEngineFactory factory) { + if (name == null || factory == null) throw new NullPointerException(); + nameAssociations.put(name, factory); + } + + public void registerEngineMimeType(String type, ScriptEngineFactory factory) { + if (type == null || factory == null) throw new NullPointerException(); + } + + public void registerEngineExtension(String extension, ScriptEngineFactory factory) { + if (extension == null || factory == null) throw new NullPointerException(); + } +} diff --git a/Android/app/src/main/java/javax/script/ScriptException.java b/Android/app/src/main/java/javax/script/ScriptException.java new file mode 100644 index 0000000..def7ce6 --- /dev/null +++ b/Android/app/src/main/java/javax/script/ScriptException.java @@ -0,0 +1,39 @@ +package javax.script; + +public class ScriptException extends Exception { + private final String fileName; + private final int lineNumber; + private final int columnNumber; + + public ScriptException(String s) { + super(s); + this.fileName = null; + this.lineNumber = -1; + this.columnNumber = -1; + } + + public ScriptException(Exception e) { + super(e); + this.fileName = null; + this.lineNumber = -1; + this.columnNumber = -1; + } + + public ScriptException(String message, String fileName, int lineNumber) { + super(message); + this.fileName = fileName; + this.lineNumber = lineNumber; + this.columnNumber = -1; + } + + public ScriptException(String message, String fileName, int lineNumber, int columnNumber) { + super(message); + this.fileName = fileName; + this.lineNumber = lineNumber; + this.columnNumber = columnNumber; + } + + public int getLineNumber() { return lineNumber; } + public int getColumnNumber() { return columnNumber; } + public String getFileName() { return fileName; } +} diff --git a/Android/app/src/main/java/javax/script/SimpleBindings.java b/Android/app/src/main/java/javax/script/SimpleBindings.java new file mode 100644 index 0000000..7b32d02 --- /dev/null +++ b/Android/app/src/main/java/javax/script/SimpleBindings.java @@ -0,0 +1,104 @@ +package javax.script; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +public class SimpleBindings implements Bindings { + private final Map map; + + public SimpleBindings(Map m) { + if (m == null) { + throw new NullPointerException("Map cannot be null"); + } + this.map = m; + } + + public SimpleBindings() { + this(new HashMap()); + } + + @Override + public Object put(String name, Object value) { + checkKey(name); + return map.put(name, value); + } + + @Override + public void putAll(Map toMerge) { + if (toMerge == null) { + throw new NullPointerException("toMerge map cannot be null"); + } + for (Map.Entry entry : toMerge.entrySet()) { + String key = entry.getKey(); + checkKey(key); + put(key, entry.getValue()); + } + } + + @Override + public void clear() { + map.clear(); + } + + @Override + public boolean containsKey(Object key) { + checkKey(key); + return map.containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return map.containsValue(value); + } + + @Override + public Set> entrySet() { + return map.entrySet(); + } + + @Override + public Object get(Object key) { + checkKey(key); + return map.get(key); + } + + @Override + public boolean isEmpty() { + return map.isEmpty(); + } + + @Override + public Set keySet() { + return map.keySet(); + } + + @Override + public Object remove(Object key) { + checkKey(key); + return map.remove(key); + } + + @Override + public int size() { + return map.size(); + } + + @Override + public Collection values() { + return map.values(); + } + + private void checkKey(Object key) { + if (key == null) { + throw new NullPointerException("Key cannot be null"); + } + if (!(key instanceof String)) { + throw new ClassCastException("Key must be a String"); + } + if (((String) key).isEmpty()) { + throw new IllegalArgumentException("Key cannot be empty"); + } + } +} diff --git a/Android/app/src/main/java/javax/script/SimpleScriptContext.java b/Android/app/src/main/java/javax/script/SimpleScriptContext.java new file mode 100644 index 0000000..4e37ced --- /dev/null +++ b/Android/app/src/main/java/javax/script/SimpleScriptContext.java @@ -0,0 +1,161 @@ +package javax.script; + +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.io.Reader; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class SimpleScriptContext implements ScriptContext { + protected Writer writer; + protected Writer errorWriter; + protected Reader reader; + protected Bindings engineScope; + protected Bindings globalScope; + + private static final List scopes; + + static { + List list = new ArrayList(2); + list.add(ENGINE_SCOPE); + list.add(GLOBAL_SCOPE); + scopes = Collections.unmodifiableList(list); + } + + public SimpleScriptContext() { + engineScope = new SimpleBindings(); + globalScope = null; + reader = new InputStreamReader(System.in); + writer = new PrintWriter(System.out, true); + errorWriter = new PrintWriter(System.err, true); + } + + @Override + public void setBindings(Bindings bindings, int scope) { + switch (scope) { + case ENGINE_SCOPE: + if (bindings == null) { + throw new NullPointerException("Engine scope cannot be null."); + } + engineScope = bindings; + break; + case GLOBAL_SCOPE: + globalScope = bindings; + break; + default: + throw new IllegalArgumentException("Invalid scope value: " + scope); + } + } + + @Override + public Bindings getBindings(int scope) { + if (scope == ENGINE_SCOPE) { + return engineScope; + } else if (scope == GLOBAL_SCOPE) { + return globalScope; + } else { + throw new IllegalArgumentException("Illegal scope value: " + scope); + } + } + + @Override + public void setAttribute(String name, Object value, int scope) { + checkName(name); + switch (scope) { + case ENGINE_SCOPE: + engineScope.put(name, value); + return; + case GLOBAL_SCOPE: + if (globalScope != null) { + globalScope.put(name, value); + } + return; + default: + throw new IllegalArgumentException("Illegal scope value: " + scope); + } + } + + @Override + public Object getAttribute(String name, int scope) { + checkName(name); + switch (scope) { + case ENGINE_SCOPE: + return engineScope.get(name); + case GLOBAL_SCOPE: + if (globalScope != null) { + return globalScope.get(name); + } + return null; + default: + throw new IllegalArgumentException("Illegal scope value: " + scope); + } + } + + @Override + public Object removeAttribute(String name, int scope) { + checkName(name); + switch (scope) { + case ENGINE_SCOPE: + if (getBindings(ENGINE_SCOPE) != null) { + return getBindings(ENGINE_SCOPE).remove(name); + } + return null; + case GLOBAL_SCOPE: + if (getBindings(GLOBAL_SCOPE) != null) { + return getBindings(GLOBAL_SCOPE).remove(name); + } + return null; + default: + throw new IllegalArgumentException("Illegal scope value: " + scope); + } + } + + @Override + public Object getAttribute(String name) { + checkName(name); + if (engineScope.containsKey(name)) { + return engineScope.get(name); + } else if (globalScope != null && globalScope.containsKey(name)) { + return globalScope.get(name); + } + return null; + } + + @Override + public int getAttributesScope(String name) { + checkName(name); + if (engineScope.containsKey(name)) { + return ENGINE_SCOPE; + } else if (globalScope != null && globalScope.containsKey(name)) { + return GLOBAL_SCOPE; + } else { + return -1; + } + } + + @Override + public Writer getWriter() { return writer; } + @Override + public Writer getErrorWriter() { return errorWriter; } + @Override + public void setWriter(Writer writer) { this.writer = writer; } + @Override + public void setErrorWriter(Writer writer) { this.errorWriter = writer; } + @Override + public Reader getReader() { return reader; } + @Override + public void setReader(Reader reader) { this.reader = reader; } + @Override + public List getScopes() { return scopes; } + + private void checkName(String name) { + if (name == null) { + throw new NullPointerException("Name cannot be null"); + } + if (name.isEmpty()) { + throw new IllegalArgumentException("Name cannot be empty"); + } + } +} diff --git a/Android/app/src/main/jniLibs/README.md b/Android/app/src/main/jniLibs/README.md new file mode 100644 index 0000000..746ce9a --- /dev/null +++ b/Android/app/src/main/jniLibs/README.md @@ -0,0 +1,16 @@ +# HKMC ASK Android JNI integration + +P3의 `NativeSeedKeyProvider`는 ABI별 Android 공유 라이브러리를 다음 위치에서 로드합니다. + +```text +app/src/main/jniLibs/arm64-v8a/libHKMC_ASK.so +app/src/main/jniLibs/armeabi-v7a/libHKMC_ASK.so # 해당 ABI를 지원할 때만 +``` + +이 라이브러리는 아래 JNI 메서드를 구현해야 합니다. + +```text +com.dct.app.data.maintenance.NativeSeedKeyProvider.nativeGenerateKey(byte[] seed): byte[] +``` + +PC의 `HKMC_AdvancedSeedKey_Win32.dll`은 Windows 바이너리이므로 Android에 복사해 사용할 수 없습니다. 벤더가 동일 알고리즘의 Android ABI 빌드와 JNI 심볼을 제공하기 전에는 앱이 키를 추정하거나 시뮬레이션하지 않으며, 장비에 PC 규격 오류 응답 `[02 02 00]`을 전송합니다. diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..58ad548 --- /dev/null +++ b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..58ad548 --- /dev/null +++ b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..7ff1186 Binary files /dev/null and b/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png b/Android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png new file mode 100644 index 0000000..d8c21b9 Binary files /dev/null and b/Android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png differ diff --git a/Android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png b/Android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000..3723bee Binary files /dev/null and b/Android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png differ diff --git a/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..69894d1 Binary files /dev/null and b/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png b/Android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png new file mode 100644 index 0000000..eb9da60 Binary files /dev/null and b/Android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png differ diff --git a/Android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png b/Android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000..60ddf24 Binary files /dev/null and b/Android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png differ diff --git a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4b3b878 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png new file mode 100644 index 0000000..097eab2 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png differ diff --git a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000..3a5c70b Binary files /dev/null and b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png differ diff --git a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..921164d Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png new file mode 100644 index 0000000..cd76eea Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png differ diff --git a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000..7950916 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png differ diff --git a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..3aa53a8 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png new file mode 100644 index 0000000..3a6b350 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png differ diff --git a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 0000000..94a06ae Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png differ diff --git a/Android/app/src/main/res/values/colors.xml b/Android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..bae42b9 --- /dev/null +++ b/Android/app/src/main/res/values/colors.xml @@ -0,0 +1,21 @@ + + + #003D9B + #0052CC + #D9E2FF + #FFFFFF + + + #121418 + #1A1C22 + #F8F9FA + #E1E2E8 + #C4C6D0 + #44474F + + + #00C853 + #FFD600 + #D50000 + #00E5FF + diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..46e18d1 --- /dev/null +++ b/Android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + DCT + DCT Data Calibration & Testing + diff --git a/Android/app/src/main/res/values/themes.xml b/Android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..af177da --- /dev/null +++ b/Android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + diff --git a/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt new file mode 100644 index 0000000..d70e5b1 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt @@ -0,0 +1,177 @@ +package com.dct.app.tests + +import com.dct.app.data.parser.A2lParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream + +class A2lParserTest { + + @Test + fun testA2lParsing() { + val sampleA2l = """ + ASAP2_VERSION 1 60 + /begin PROJECT TestProject "Project Description" + /begin MODULE TestModule "Module Description" + + /begin IF_DATA XCP + /begin XCP_ON_CAN + CAN_ID_MASTER 0x7E0 + CAN_ID_SLAVE 0x7E8 + BAUDRATE 500000 + BYTE_ORDER MSB_LAST + /end XCP_ON_CAN + /end IF_DATA + + /begin COMPU_VTAB StateVTab "State verbal mapping" + TAB_VERB + 0 "INIT" + 1 "RUNNING" + 2 "ERROR" + /end COMPU_VTAB + + /begin COMPU_TAB IntpTab "Piecewise linear mapping" + TAB_INTP + 0.0 0.0 + 50.0 100.0 + 100.0 300.0 + /end COMPU_TAB + + /begin COMPU_METHOD EngineSpeedConv "Engine Speed conversion" + LINEAR "%6.2f" "rpm" + COEFFS_LINEAR 0.25 0.0 + /end COMPU_METHOD + + /begin COMPU_METHOD ThrottlePosConv "Throttle Pos conversion" + LINEAR "%5.1f" "%" + COEFFS_LINEAR 0.1 0.0 + /end COMPU_METHOD + + /begin COMPU_METHOD StateConv "State conversion" + TAB_VERB "%s" "" StateVTab + /end COMPU_METHOD + + /begin COMPU_METHOD CustomCurveConv "Custom curve conversion" + TAB_INTP "%6.1f" "bar" IntpTab + /end COMPU_METHOD + + /begin MEASUREMENT EngineSpeed "Current engine speed in RPM" + UWORD EngineSpeedConv 1 0.0 0.0 8000.0 + ECU_ADDRESS 0x70001000 + FORMAT "%6.2f" + /end MEASUREMENT + + /begin MEASUREMENT ThrottlePos "Throttle valve position" + UBYTE ThrottlePosConv 1 0.0 0.0 100.0 + ECU_ADDRESS 0x70001004 + FORMAT "%5.1f" + /end MEASUREMENT + + /begin MEASUREMENT TempArray "Temperature Array" + SWORD NO_COMPU_METHOD 1 0.0 -40.0 150.0 + ECU_ADDRESS 0x70001008 + MATRIX_DIM 2 + /end MEASUREMENT + + /begin CHARACTERISTIC K_IDLE_SPEED "Idle Target Speed" + VALUE + 0x70002000 + Deposit_UWORD + 0.0 + EngineSpeedConv + 600.0 + 1200.0 + FORMAT "%6.2f" + UNIT "rpm" + /end CHARACTERISTIC + + /begin CHARACTERISTIC MAP_THROTTLE_CURVE "Throttle Map Array" + VAL_BLK + 0x70002020 + Deposit_UWORD + 0.0 + ThrottlePosConv + 0.0 + 100.0 + MATRIX_DIM 4 + FORMAT "%5.1f" + UNIT "%" + /end CHARACTERISTIC + + /end MODULE + /end PROJECT + """.trimIndent() + + val inputStream = ByteArrayInputStream(sampleA2l.toByteArray(Charsets.UTF_8)) + val result = A2lParser.parseA2lFull(inputStream) + + // 1. IF_DATA 검증 + assertNotNull(result.ifData) + assertEquals(0x7E0L, result.ifData!!.masterCanId) + assertEquals(0x7E8L, result.ifData!!.slaveCanId) + assertEquals(500000, result.ifData!!.baudrate) + assertEquals(false, result.ifData!!.isMsbFirst) + + // 2. COMPU_VTAB 및 COMPU_TAB 검증 + val stateMethod = result.compuMethods["stateconv"] + assertNotNull(stateMethod) + assertEquals("INIT", stateMethod!!.getVerbalString(0.0)) + assertEquals("RUNNING", stateMethod.getVerbalString(1.0)) + assertEquals("ERROR", stateMethod.getVerbalString(2.0)) + + val curveMethod = result.compuMethods["customcurveconv"] + assertNotNull(curveMethod) + assertEquals(0.0, curveMethod!!.calculatePhysicalValue(0.0), 0.001) + assertEquals(50.0, curveMethod.calculatePhysicalValue(25.0), 0.001) // 0~50 interpolate + assertEquals(100.0, curveMethod.calculatePhysicalValue(50.0), 0.001) + assertEquals(200.0, curveMethod.calculatePhysicalValue(75.0), 0.001) // 50~100 interpolate + assertEquals(300.0, curveMethod.calculatePhysicalValue(100.0), 0.001) + + // 3. Measurements 검증 + val signals = result.measurements + assertEquals(4, signals.size) // EngineSpeed, ThrottlePos, TempArray[0], TempArray[1] + + val speed = signals.find { it.name == "EngineSpeed" } + assertNotNull(speed) + assertEquals(0x70001000.toInt(), speed!!.adl) + assertEquals("rpm", speed.unit) + assertEquals(0.25, speed.cvsA, 0.0001) + assertEquals(8000.0, speed.max, 0.0001) + + val throttle = signals.find { it.name == "ThrottlePos" } + assertNotNull(throttle) + assertEquals(0x70001004.toInt(), throttle!!.adl) + assertEquals("%", throttle.unit) + assertEquals(0.1, throttle.cvsA, 0.0001) + + val temp0 = signals.find { it.name == "TempArray[0]" } + val temp1 = signals.find { it.name == "TempArray[1]" } + assertNotNull(temp0) + assertNotNull(temp1) + assertEquals(0x70001008.toInt(), temp0!!.adl) + assertEquals(0x7000100A.toInt(), temp1!!.adl) // SWORD = 2 bytes offset + + // 4. Characteristics 검증 + val characteristics = result.characteristics + assertEquals(1, characteristics.size) + val idleSpeed = characteristics[0] + assertEquals("K_IDLE_SPEED", idleSpeed.name) + assertEquals(0x70002000L, idleSpeed.ecuAddress) + assertEquals("rpm", idleSpeed.unit) + assertEquals("VALUE", idleSpeed.type) + assertEquals(600.0, idleSpeed.lowerLimit, 0.0001) + assertEquals(1200.0, idleSpeed.upperLimit, 0.0001) + + // 5. CharacteristicsArray 검증 + val charArrays = result.characteristicsArray + assertEquals(1, charArrays.size) + val throttleCurve = charArrays[0] + assertEquals("MAP_THROTTLE_CURVE", throttleCurve.name) + assertEquals(0x70002020L, throttleCurve.ecuAddress) + assertEquals("VAL_BLK", throttleCurve.type) + assertEquals(listOf(4), throttleCurve.matrixDim) + assertEquals(4, throttleCurve.values.size) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/AttrConverterTest.kt b/Android/app/src/test/java/com/dct/app/tests/AttrConverterTest.kt new file mode 100644 index 0000000..32bf921 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/AttrConverterTest.kt @@ -0,0 +1,213 @@ +package com.dct.app.tests + +import com.google.common.truth.Truth.assertThat +import com.dct.app.data.mdb.AttrConverter +import com.dct.app.domain.model.mdb.MdbAttrType +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +@DisplayName("M3 AttrConverter: ATTR 0~5 Little-Endian 언팩/패킹 및 물리값 선형 변환 검증") +class AttrConverterTest { + + // ========================================================================= + // 1. ATTR 0: 1-Byte Unsigned Integer (0 ~ 255) + // ========================================================================= + + @Test + @DisplayName("ATTR 0: 0, 127, 255 경계값 언팩 및 패킹 검증") + fun testAttr0UnpackAndPack() { + val testValues = listOf(0L, 1L, 127L, 128L, 254L, 255L) + for (v in testValues) { + val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_0_UINT8) + assertThat(packed.size).isEqualTo(1) + val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_0_UINT8) + assertThat(unpacked).isEqualTo(v) + } + } + + // ========================================================================= + // 2. ATTR 1: 1-Byte Signed Integer (-128 ~ 127) + // ========================================================================= + + @Test + @DisplayName("ATTR 1: -128, -1, 0, 1, 127 경계값 언팩 및 패킹 검증") + fun testAttr1UnpackAndPack() { + val testValues = listOf(-128L, -100L, -1L, 0L, 1L, 100L, 127L) + for (v in testValues) { + val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_1_INT8) + assertThat(packed.size).isEqualTo(1) + val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_1_INT8) + assertThat(unpacked).isEqualTo(v) + } + } + + // ========================================================================= + // 3. ATTR 2: 2-Byte Unsigned Integer (0 ~ 65535, Little-Endian) + // ========================================================================= + + @Test + @DisplayName("ATTR 2: 0, 256, 32767, 65535 경계값 및 Little-Endian 바이트 오더 검증") + fun testAttr2UnpackAndPack() { + val testValues = listOf(0L, 1L, 255L, 256L, 1000L, 32767L, 32768L, 65534L, 65535L) + for (v in testValues) { + val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_2_UINT16) + assertThat(packed.size).isEqualTo(2) + + // Little-Endian 검증: 하위 바이트 우선 + val expectedB0 = (v and 0xFFL).toByte() + val expectedB1 = ((v shr 8) and 0xFFL).toByte() + assertThat(packed[0]).isEqualTo(expectedB0) + assertThat(packed[1]).isEqualTo(expectedB1) + + val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_2_UINT16) + assertThat(unpacked).isEqualTo(v) + } + } + + // ========================================================================= + // 4. ATTR 3: 2-Byte Signed Integer (-32768 ~ 32767, Little-Endian) + // ========================================================================= + + @Test + @DisplayName("ATTR 3: -32768, -1, 0, 1, 32767 음수/양수 경계값 언팩 및 패킹 검증") + fun testAttr3UnpackAndPack() { + val testValues = listOf(-32768L, -10000L, -256L, -1L, 0L, 1L, 256L, 10000L, 32767L) + for (v in testValues) { + val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_3_INT16) + assertThat(packed.size).isEqualTo(2) + + val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_3_INT16) + assertThat(unpacked).isEqualTo(v) + } + } + + // ========================================================================= + // 5. ATTR 4: 4-Byte Unsigned Integer (0 ~ 4294967295, Little-Endian) + // ========================================================================= + + @Test + @DisplayName("ATTR 4: 0, 65536, 2147483647, 4294967295 경계값 언팩 및 패킹 검증") + fun testAttr4UnpackAndPack() { + val testValues = listOf(0L, 1L, 65535L, 65536L, 2147483647L, 2147483648L, 4294967295L) + for (v in testValues) { + val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_4_UINT32) + assertThat(packed.size).isEqualTo(4) + + // Little-Endian 4바이트 검증 + val b0 = (v and 0xFFL).toByte() + val b1 = ((v shr 8) and 0xFFL).toByte() + val b2 = ((v shr 16) and 0xFFL).toByte() + val b3 = ((v shr 24) and 0xFFL).toByte() + assertThat(packed).isEqualTo(byteArrayOf(b0, b1, b2, b3)) + + val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_4_UINT32) + assertThat(unpacked).isEqualTo(v) + } + } + + // ========================================================================= + // 6. ATTR 5: 4-Byte Signed Integer (-2147483648 ~ 2147483647, Little-Endian) + // ========================================================================= + + @Test + @DisplayName("ATTR 5: -2147483648, -1, 0, 1, 2147483647 32비트 부호 정수 경계값 검증") + fun testAttr5UnpackAndPack() { + val testValues = listOf(-2147483648L, -1000000L, -1L, 0L, 1L, 1000000L, 2147483647L) + for (v in testValues) { + val packed = AttrConverter.packRawValue(v, MdbAttrType.ATTR_5_INT32) + assertThat(packed.size).isEqualTo(4) + + val unpacked = AttrConverter.unpackRawValue(packed, 0, MdbAttrType.ATTR_5_INT32) + assertThat(unpacked).isEqualTo(v) + } + } + + // ========================================================================= + // 7. 물리값 선형 변환 수식: Y = A * X + B 및 X = (Y - B) / A + // ========================================================================= + + @Test + @DisplayName("물리값 선형 변환 수식(Y = A * X + B) 및 역변환(X = (Y - B) / A) 정밀도 검증") + fun testPhysicalFormulaScalingAndInverse() { + // 케이스 1: 엔진 RPM (CVS_A = 1.0, CVS_B = 0.0) -> 850 raw -> 850.0 rpm + val rpmPhysical = AttrConverter.toPhysicalValue(850, 1.0, 0.0) + assertThat(rpmPhysical).isEqualTo(850.0) + val rpmRaw = AttrConverter.toRawValue(850.0, 1.0, 0.0) + assertThat(rpmRaw).isEqualTo(850.0) + + // 케이스 2: 냉각수 온도 (CVS_A = 0.1, CVS_B = -40.0) -> 1250 raw -> 85.0 °C + val tempPhysical = AttrConverter.toPhysicalValue(1250, 0.1, -40.0) + assertThat(tempPhysical).isEqualTo(85.0) + val tempRaw = AttrConverter.toRawValue(85.0, 0.1, -40.0) + assertThat(tempRaw).isEqualTo(1250.0) + + // 케이스 3: 스로틀 개도율 (CVS_A = 0.5, CVS_B = 0.0) -> 150 raw -> 75.0 % + val throttlePhysical = AttrConverter.toPhysicalValue(150, 0.5, 0.0) + assertThat(throttlePhysical).isEqualTo(75.0) + val throttleRaw = AttrConverter.toRawValue(75.0, 0.5, 0.0) + assertThat(throttleRaw).isEqualTo(150.0) + + // 케이스 4: 클러치 유압 (CVS_A = 0.05, CVS_B = 0.0) -> 600 raw -> 30.0 bar + val clutchPhysical = AttrConverter.toPhysicalValue(600, 0.05, 0.0) + assertThat(clutchPhysical).isEqualTo(30.0) + val clutchRaw = AttrConverter.toRawValue(30.0, 0.05, 0.0) + assertThat(clutchRaw).isEqualTo(600.0) + + // 케이스 5: 0 나눗셈 방어 + val divZero = AttrConverter.toRawValue(100.0, 0.0, 10.0) + assertThat(divZero).isEqualTo(0.0) + } + + // ========================================================================= + // 8. 패킷 오프셋 기반 unpackPhysicalValue 통합 검증 + // ========================================================================= + + @Test + @DisplayName("복합 바이트 버퍼에서 오프셋별 unpackPhysicalValue 연속 파싱 검증") + fun testUnpackPhysicalValueFromCombinedBuffer() { + // 가상 패킷 페이로드: + // [0..3]: EngineRPM (ATTR 4, cvsA 1.0, cvsB 0.0) = 3500 (0x00000DAC -> AC 0D 00 00) + // [4..5]: CoolantTemp (ATTR 2, cvsA 0.1, cvsB -40.0) = 1350 (0x0546 -> 46 05) -> 95.0 °C + // [6]: ThrottlePos (ATTR 0, cvsA 0.5, cvsB 0.0) = 120 (0x78) -> 60.0 % + // [7..8]: IntakePressure (ATTR 2, cvsA 0.1, cvsB 0.0) = 1850 (0x073A -> 3A 07) -> 185.0 kPa + val buffer = byteArrayOf( + 0xAC.toByte(), 0x0D, 0x00, 0x00, // 3500 + 0x46, 0x05, // 1350 + 0x78, // 120 + 0x3A, 0x07 // 1850 + ) + + val rpm = AttrConverter.unpackPhysicalValue(buffer, 0, MdbAttrType.ATTR_4_UINT32, 1.0, 0.0) + assertThat(rpm).isEqualTo(3500.0) + + val temp = AttrConverter.unpackPhysicalValue(buffer, 4, MdbAttrType.ATTR_2_UINT16, 0.1, -40.0) + assertThat(temp).isEqualTo(95.0) + + val throttle = AttrConverter.unpackPhysicalValue(buffer, 6, MdbAttrType.ATTR_0_UINT8, 0.5, 0.0) + assertThat(throttle).isEqualTo(60.0) + + val pressure = AttrConverter.unpackPhysicalValue(buffer, 7, MdbAttrType.ATTR_2_UINT16, 0.1, 0.0) + assertThat(pressure).isEqualTo(185.0) + } + + // ========================================================================= + // 9. 버퍼 언더플로우 / 경계 예외 방어 검증 + // ========================================================================= + + @Test + @DisplayName("버퍼 크기 부족 시 IllegalArgumentException 예외 발생 검증") + fun testBufferUnderflowThrowsException() { + val shortBuffer = byteArrayOf(0x01, 0x02) + + // ATTR 4는 4바이트 필요하지만 버퍼는 2바이트 -> 예외 + assertThrows { + AttrConverter.unpackRawValue(shortBuffer, 0, MdbAttrType.ATTR_4_UINT32) + } + + // 오프셋 초과 -> 예외 + assertThrows { + AttrConverter.unpackRawValue(shortBuffer, 2, MdbAttrType.ATTR_2_UINT16) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/CalibrationEditorTest.kt b/Android/app/src/test/java/com/dct/app/tests/CalibrationEditorTest.kt new file mode 100644 index 0000000..b307695 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/CalibrationEditorTest.kt @@ -0,0 +1,236 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DctResult +import com.dct.app.core.common.DefaultDispatcherProvider +import com.dct.app.domain.model.mdb.* +import com.dct.app.domain.repository.MdbRepository +import com.dct.app.presentation.calibration.CalibrationEditorMode +import com.dct.app.presentation.calibration.CalibrationViewModel +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("M5 - CalibrationViewModel 캘리브레이션 튜너 및 맵 편집기 단위 테스트") +class CalibrationEditorTest { + + private lateinit var mdbRepository: MdbRepository + private lateinit var dispatchers: DefaultDispatcherProvider + private lateinit var viewModel: CalibrationViewModel + + private lateinit var sampleConstant: MdbConstant + private lateinit var sampleMap: CompensationMap + + @BeforeEach + fun setUp() { + mdbRepository = mockk(relaxed = true) + dispatchers = DefaultDispatcherProvider() + viewModel = CalibrationViewModel(mdbRepository, dispatchers) + + sampleConstant = MdbConstant( + idx = 1, + name = "K_ENG_IDLE_TARGET", + data = 750.0, + addr = 0x1020L, + min = 600.0, + max = 1200.0, + attr = MdbAttrType.ATTR_2_UINT16, + description = "엔진 아이들 목표 속도" + ) + + val points = listOf( + MapPoint(idx = 1, bkp = 1000, data = 100, phy = 25.0), + MapPoint(idx = 2, bkp = 2000, data = 200, phy = 50.0), + MapPoint(idx = 3, bkp = 3000, data = 300, phy = 75.0) + ) + sampleMap = CompensationMap( + type = CompensationMapType.TYPE_A, + idx = 1, + yName = "TB_TQ_OFFSET", + yAddr = 0x2050L, + yAttr = MdbAttrType.ATTR_2_UINT16, + yMin = 0.0, + yMax = 100.0, + xName = "RPM_AXIS", + xAddr = 0x2060L, + xAttr = MdbAttrType.ATTR_2_UINT16, + cvsA = 0.25, + cvsB = 0.0, + description = "토크 보정 맵", + points = points + ) + } + + @Test + @DisplayName("상수 선택 시 초기 상태가 정상 구성되어야 한다") + fun testSelectConstantInitialState() { + viewModel.selectConstant(sampleConstant) + + val state = viewModel.uiState.value + assertEquals(CalibrationEditorMode.CONSTANT, state.mode) + assertNotNull(state.constantState) + val constState = state.constantState!! + assertEquals(750.0, constState.currentValue) + assertEquals("750.0", constState.textValue) + assertTrue(constState.isValid) + assertFalse(constState.isDirty) + } + + @Test + @DisplayName("상수 입력값 변경 시 정상 범위이면 isValid=true 및 dirty=true가 되어야 한다") + fun testConstantValidTextChange() { + viewModel.selectConstant(sampleConstant) + viewModel.onConstantTextChanged("850.0") + + val state = viewModel.uiState.value.constantState!! + assertEquals(850.0, state.currentValue) + assertTrue(state.isValid) + assertTrue(state.isDirty) + assertNull(state.errorMessage) + } + + @Test + @DisplayName("상수 입력값이 Min/Max 범위를 벗어나면 isValid=false 및 에러 메시지가 설정되어야 한다") + fun testConstantOutOfRangeValidation() { + viewModel.selectConstant(sampleConstant) + + // 1. Max(1200) 초과 + viewModel.onConstantTextChanged("1500.0") + val state1 = viewModel.uiState.value.constantState!! + assertFalse(state1.isValid) + assertNotNull(state1.errorMessage) + + // 2. Min(600) 미만 + viewModel.onConstantTextChanged("400.0") + val state2 = viewModel.uiState.value.constantState!! + assertFalse(state2.isValid) + assertNotNull(state2.errorMessage) + + // 3. 비정상 텍스트 + viewModel.onConstantTextChanged("abc") + val state3 = viewModel.uiState.value.constantState!! + assertFalse(state3.isValid) + } + + @Test + @DisplayName("상수 슬라이더 이동 시 값이 소수점 2자리로 정밀 갱신되어야 한다") + fun testConstantSliderChange() { + viewModel.selectConstant(sampleConstant) + viewModel.onConstantSliderChanged(825.456) + + val state = viewModel.uiState.value.constantState!! + assertEquals(825.46, state.currentValue, 0.01) + assertTrue(state.isDirty) + assertTrue(state.isValid) + } + + @Test + @DisplayName("상수 초기화(resetConstant) 시 원래 값으로 복원되어야 한다") + fun testResetConstant() { + viewModel.selectConstant(sampleConstant) + viewModel.onConstantTextChanged("900.0") + assertTrue(viewModel.uiState.value.constantState!!.isDirty) + + viewModel.resetConstant() + val state = viewModel.uiState.value.constantState!! + assertEquals(750.0, state.currentValue) + assertFalse(state.isDirty) + } + + @Test + @DisplayName("상수 Live Commit 시 MdbRepository.updateConstant가 호출되고 성공 피드백이 갱신되어야 한다") + fun testCommitConstant() = runTest { + coEvery { mdbRepository.updateConstant("K_ENG_IDLE_TARGET", 850.0) } returns DctResult.Success(Unit) + + viewModel.selectConstant(sampleConstant) + viewModel.onConstantTextChanged("850.0") + viewModel.commitConstant() + + coVerify { mdbRepository.updateConstant("K_ENG_IDLE_TARGET", 850.0) } + val state = viewModel.uiState.value + assertTrue(state.isSuccessFeedback) + assertNotNull(state.statusMessage) + } + + @Test + @DisplayName("보정 맵 선택 시 포인트 목록이 복사되어 초기화되어야 한다") + fun testSelectCompensationMap() { + viewModel.selectCompensationMap(sampleMap) + + val state = viewModel.uiState.value + assertEquals(CalibrationEditorMode.COMPENSATION_MAP, state.mode) + assertNotNull(state.mapState) + val mapState = state.mapState!! + assertEquals(3, mapState.editedPoints.size) + assertFalse(mapState.isDirty) + } + + @Test + @DisplayName("보정 맵에서 Raw Data 수정 시 Y=AX+B 공식에 따라 Physical 값이 자동 계산되어야 한다") + fun testUpdateMapPointRawData() { + viewModel.selectCompensationMap(sampleMap) + + // cvsA = 0.25, cvsB = 0.0 -> Raw=400 입력 시 Phy = 400 * 0.25 + 0.0 = 100.0 + viewModel.updateMapPoint(index = 0, newBkp = 1000, newRawData = 400, newPhy = null) + + val mapState = viewModel.uiState.value.mapState!! + val pt0 = mapState.editedPoints[0] + assertEquals(400, pt0.data) + assertEquals(100.0, pt0.phy, 0.001) + assertTrue(mapState.isDirty) + } + + @Test + @DisplayName("보정 맵에서 Physical 값 수정 시 Raw Data가 역산되어야 한다") + fun testUpdateMapPointPhysicalValue() { + viewModel.selectCompensationMap(sampleMap) + + // cvsA = 0.25, cvsB = 0.0 -> Phy=60.0 입력 시 Raw = (60.0 - 0.0) / 0.25 = 240 + viewModel.updateMapPoint(index = 1, newBkp = 2000, newRawData = null, newPhy = 60.0) + + val mapState = viewModel.uiState.value.mapState!! + val pt1 = mapState.editedPoints[1] + assertEquals(240, pt1.data) + assertEquals(60.0, pt1.phy, 0.001) + assertTrue(mapState.isDirty) + } + + @Test + @DisplayName("보정 맵 포인트 추가 및 삭제가 정상 동작해야 한다 (최소 2개 보장)") + fun testAddAndDeleteMapPoint() { + viewModel.selectCompensationMap(sampleMap) + assertEquals(3, viewModel.uiState.value.mapState!!.editedPoints.size) + + // 1. 포인트 추가 + viewModel.addMapPoint() + assertEquals(4, viewModel.uiState.value.mapState!!.editedPoints.size) + + // 2. 포인트 삭제 + viewModel.deleteMapPoint(3) + assertEquals(3, viewModel.uiState.value.mapState!!.editedPoints.size) + + viewModel.deleteMapPoint(2) + assertEquals(2, viewModel.uiState.value.mapState!!.editedPoints.size) + + // 최소 2개 남아있을 때 삭제 시도 -> 삭제되지 않음 + viewModel.deleteMapPoint(1) + assertEquals(2, viewModel.uiState.value.mapState!!.editedPoints.size) + } + + @Test + @DisplayName("보정 맵 Live Commit 시 MdbRepository로 포인트 목록 및 메타데이터가 정상 저장되어야 한다") + fun testCommitCompensationMap() = runTest { + coEvery { mdbRepository.updateCompensationMap(any(), any()) } returns DctResult.Success(Unit) + coEvery { mdbRepository.updateCompensationMetadata(any(), any(), any(), any(), any(), any(), any()) } returns DctResult.Success(Unit) + + viewModel.selectCompensationMap(sampleMap) + viewModel.addMapPoint() + viewModel.commitMap() + + coVerify { mdbRepository.updateCompensationMap("TB_TQ_OFFSET", any()) } + coVerify { mdbRepository.updateCompensationMetadata(true, "TB_TQ_OFFSET", any(), any(), any(), any(), any()) } + assertTrue(viewModel.uiState.value.isSuccessFeedback) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/CanDbcParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/CanDbcParserTest.kt new file mode 100644 index 0000000..83a5c5a --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/CanDbcParserTest.kt @@ -0,0 +1,108 @@ +package com.dct.app.tests + +import com.dct.app.core.util.TextStreamDecoder +import com.dct.app.data.parser.CanDbcParser +import com.dct.app.domain.model.mdb.CanMultiplexRole +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import java.io.ByteArrayInputStream +import java.nio.charset.Charset + +class CanDbcParserTest { + + @Test + fun testCanDbcParsingUtf8() { + val sampleDbc = """ + VERSION "" + BO_ 512 ALDC_Status: 8 ALDC + SG_ ALDC_SysTempVal : 0|8@1+ (1,-40) [-40|150] "℃" Vector__XXX + SG_ ALDC_PwrMonVal : 8|16@1+ (0.1,0) [0|1000] "W" Vector__XXX + SG_ ALDC_EwpRpmCtrlVal : 24|16@1+ (1,0) [0|10000] "rpm" Vector__XXX + """.trimIndent() + + val inputStream = ByteArrayInputStream(sampleDbc.toByteArray(Charsets.UTF_8)) + val signals = CanDbcParser.parseDbc(inputStream) + + assertEquals(3, signals.size) + val tempSig = signals.find { it.name == "ALDC_SysTempVal" } + assertNotNull(tempSig) + assertEquals("℃", tempSig!!.unit) + assertEquals(-40.0, tempSig.cvsB, 0.001) + assertEquals("ALDC_Status", tempSig.messageName) + assertEquals(512L, tempSig.messageId) + } + + @Test + fun testCanDbcParsingEucKrEncodedSpecialUnits() { + // EUC-KR / CP949 인코딩으로 저장된 DBC 파일 시뮬레이션 + val rawDbc = """ + VERSION "" + BO_ 512 ALDC_Status: 8 ALDC + SG_ ALDC_SysTempVal : 0|8@1+ (1,-40) [-40|150] "℃" Vector__XXX + SG_ ALDC_MotorTemp : 8|8@1+ (1,-40) [-40|150] "°C" Vector__XXX + SG_ ALDC_PwrMonVal : 16|16@1+ (0.1,0) [0|1000] "W" Vector__XXX + """.trimIndent() + + val eucKrCharset = Charset.forName("EUC-KR") + val bytes = rawDbc.toByteArray(eucKrCharset) + val inputStream = ByteArrayInputStream(bytes) + + val signals = CanDbcParser.parseDbc(inputStream) + + assertEquals(3, signals.size) + val tempSig = signals.find { it.name == "ALDC_SysTempVal" } + assertNotNull(tempSig) + assertEquals("℃", tempSig!!.unit) + + val motorTempSig = signals.find { it.name == "ALDC_MotorTemp" } + assertNotNull(motorTempSig) + assertEquals("°C", motorTempSig!!.unit) + } + + @Test + fun testCanDbcParsingWindows1252DegreeUnits() { + // Windows-1252 / ISO-8859-1 (0xB0 0x43 = °C) + val rawDbc = "BO_ 512 Test: 8 XXX\n SG_ Temp : 0|8@1+ (1,0) [0|100] \"\u00B0C\" XXX\n" + val winCharset = Charset.forName("windows-1252") + val bytes = rawDbc.toByteArray(winCharset) + val inputStream = ByteArrayInputStream(bytes) + + val signals = CanDbcParser.parseDbc(inputStream) + + assertEquals(1, signals.size) + assertEquals("°C", signals[0].unit) + } + + @Test + fun testTextStreamDecoderSanitizeUnit() { + assertEquals("℃", TextStreamDecoder.sanitizeUnit("\uFFFD\uFFFD")) + assertEquals("℃", TextStreamDecoder.sanitizeUnit("'C")) + assertEquals("℃", TextStreamDecoder.sanitizeUnit("deg C")) + assertEquals("℃", TextStreamDecoder.sanitizeUnit("degC")) + assertEquals("°", TextStreamDecoder.sanitizeUnit("deg")) + assertEquals("rpm", TextStreamDecoder.sanitizeUnit("rpm")) + assertEquals("V", TextStreamDecoder.sanitizeUnit("V")) + assertEquals("W", TextStreamDecoder.sanitizeUnit("W")) + } + + @Test + fun `기본 DBC 멀티플렉서 M과 m값을 파싱한다`() { + val sampleDbc = """ + BO_ 291 MuxMessage: 8 ECU + SG_ Mode M : 0|4@1+ (1,0) [0|15] "" ECU + SG_ Value0 m0 : 8|8@1+ (1,0) [0|255] "" ECU + SG_ Value7 m7 : 8|8@1+ (1,0) [0|255] "" ECU + SG_ Always : 16|8@1+ (1,0) [0|255] "" ECU + """.trimIndent() + + val signals = CanDbcParser.parseDbc(ByteArrayInputStream(sampleDbc.toByteArray())) + + assertEquals(4, signals.size) + assertEquals(CanMultiplexRole.MULTIPLEXOR, signals[0].multiplexRole) + assertEquals(CanMultiplexRole.MULTIPLEXED, signals[1].multiplexRole) + assertEquals(0L, signals[1].multiplexValue) + assertEquals(7L, signals[2].multiplexValue) + assertEquals(CanMultiplexRole.NONE, signals[3].multiplexRole) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt new file mode 100644 index 0000000..a9f9437 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt @@ -0,0 +1,112 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.TimeWindowPreset +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ChartContinuousScrollTest { + private val presets = TimeWindowPreset.entries.filter { it.tickIntervalSec != null } + private fun TestScope.renderer() = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))).apply { + bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF0000L) + } + private fun ChartBatchRenderer.sample(time: Double) = addSampleSec("s", time, 1.0) + + @Test fun allFixedPresetsFollowEveryNewTimestampWhileKeepingSpanAndTickPreset() = runTest { + for (preset in presets) { + val r = renderer() + r.sample(10000.0) + r.setTimeWindow(preset) + for (time in listOf(10000.016, 10000.033, 10000.1, 10001.0, 10002.0)) { + r.sample(time) + val frame = r.buildSnapshot(600) + assertEquals(time, frame.timeMaxSec, 1e-8) + assertEquals(preset.displaySpanSec, frame.timeMaxSec - frame.timeMinSec, 1e-8) + assertEquals(preset, frame.xAxisPreset) + assertTrue(frame.isXAxisAuto) + } + } + } + + @Test fun clearRetainsInitialSixIntervalsThenScrollsBySampleDelta() = runTest { + for (preset in presets) { + val r = renderer() + r.setTimeWindow(preset) + r.clearAllData() + r.sample(10000.0) + val end = 10000.0 + preset.resetSpanSec + assertEquals(end, r.buildSnapshot(600).timeMaxSec) + r.sample(end - .1) + assertEquals(end, r.buildSnapshot(600).timeMaxSec) + r.sample(end) + assertEquals(end, r.buildSnapshot(600).timeMaxSec) + r.sample(end + .016) + val frame = r.buildSnapshot(600) + assertEquals(end + .016, frame.timeMaxSec, 1e-8) + assertEquals(preset.resetSpanSec, frame.timeMaxSec - frame.timeMinSec, 1e-8) + } + } + + @Test fun firstBatchLargerThanWindowDoesNotAllocateAnExtraBlankTick() = runTest { + val r = renderer() + r.setTimeWindow(TimeWindowPreset.SEC_10) + r.sample(1000.0) + r.sample(1500.0) + val frame = r.buildSnapshot(600) + assertEquals(1500.0, frame.timeMaxSec) + assertEquals(1460.0, frame.timeMinSec) + } + + @Test fun renderFlowRollsAtFrameCadenceAndStopsMovingWhenSamplesStop() = runTest { + val r = renderer() + r.sample(1000.0) + r.setTimeWindow(TimeWindowPreset.SEC_10) + r.start(backgroundScope) + r.resume() + runCurrent() + for (time in listOf(1000.033, 1000.066, 1000.099)) { + r.sample(time) + advanceTimeBy(20) + runCurrent() + assertEquals(time, r.renderSnapshotFlow.value.timeMaxSec, 1e-8) + } + advanceTimeBy(1000) + runCurrent() + assertEquals(1000.099, r.renderSnapshotFlow.value.timeMaxSec, 1e-8) + } + + @Test fun historyBrowsingStillFreezesAndDoubleTapReturnsToContinuousPresetTracking() = runTest { + val r = renderer() + r.sample(1000.0) + r.setTimeWindow(TimeWindowPreset.MIN_1) + r.panTimeWindow(-50.0) + r.sample(2000.0) + assertEquals(950.0, r.buildSnapshot(600).timeMaxSec) + r.resetPan() + r.sample(2000.033) + val frame = r.buildSnapshot(600) + assertEquals(2000.033, frame.timeMaxSec, 1e-8) + assertEquals(300.0, frame.timeMaxSec - frame.timeMinSec, 1e-8) + assertEquals(TimeWindowPreset.MIN_1, frame.xAxisPreset) + } + + @Test fun rollingWindowsDoNotChangeOtherWindowsInitialFillRange() = runTest { + val a = renderer() + val b = renderer() + a.sample(1000.0) + a.setTimeWindow(TimeWindowPreset.SEC_10) + b.setTimeWindow(TimeWindowPreset.MIN_1) + b.sample(1000.0) + val before = b.buildSnapshot(600) + a.sample(1000.5) + assertEquals(1000.5, a.buildSnapshot(600).timeMaxSec) + assertEquals(before.timeMaxSec, b.buildSnapshot(600).timeMaxSec) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt new file mode 100644 index 0000000..7f529d1 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt @@ -0,0 +1,138 @@ +package com.dct.app.tests + +import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.InterpolationMode +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ChartCursorStabilityTest { + private fun TestScope.renderer() = ChartBatchRenderer(TestDispatcherProvider(StandardTestDispatcher(testScheduler))).apply { + bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF0000L) + addSampleSec("s", 0.0, 10.0) + addSampleSec("s", 2.0, 20.0) + setTimeWindow(2.0) + } + private fun ChartBatchRenderer.value(width: Int = 600) = buildSnapshot(width).seriesList.single().cursorValue + + @Test fun originalSamplesRemainStableAcrossDownsamplingScrollingAndZoom() = runTest { + val r = renderer() + for (i in 201..1000) r.addSampleSec("s", i / 100.0, (i % 7).toDouble()) + r.setTimeWindow(10.0) + r.setCrosshairTimeSec(2.035) + assertEquals(0.5, r.value()!!, 1e-10) + val oldPoints = r.buildSnapshot(2).seriesList.single().points + for (i in 1001..1030) r.addSampleSec("s", i / 100.0, (i % 7).toDouble()) + for (width in listOf(1, 2, 10, 1200)) assertEquals(0.5, r.value(width)!!, 1e-10) + assertNotEquals(oldPoints, r.buildSnapshot(2).seriesList.single().points) + r.panTimeWindow(-1.0) + r.zoomTimeWindow(2f, .5) + assertEquals(0.5, r.value()!!, 1e-10) + } + + @Test fun linearPulseAndExactTransitionUseCapturedOriginalNeighbors() = runTest { + val r = renderer() + r.setCrosshairTimeSec(1.0) + assertEquals(15.0, r.value()) + r.setGraphInterpolationMode(InterpolationMode.PWM_STEP) + assertEquals(10.0, r.value()) + r.setCrosshairTimeSec(2.0) + assertEquals(20.0, r.value()) + r.setSeriesInterpolationMode("s", InterpolationMode.LINEAR) + assertEquals(20.0, r.value()) + } + + @Test fun overwritesAndEvictionDoNotChangeSelectionButRetappingRefreshes() = runTest { + val r = renderer() + r.setCrosshairTimeSec(2.0) + r.addSampleSec("s", 2.0, 99.0) + assertEquals(20.0, r.value()) + r.setCrosshairTimeSec(2.0) + assertEquals(99.0, r.value()) + for (i in 3..100003) r.addSampleSec("s", i.toDouble(), -1.0) + assertEquals(99.0, r.value()) + r.setCrosshairTimeSec(2.0) + assertNull(r.value()) + } + + @Test fun missingDataDoesNotFollowLatestOrBecomeAValueUntilReselected() = runTest { + val r = renderer() + for (time in listOf(-1.0, 3.0, Double.NaN, Double.POSITIVE_INFINITY)) { + r.setCrosshairTimeSec(time) + assertNull(r.value()) + } + r.setCrosshairTimeSec(3.0) + r.addSampleSec("s", 3.0, 30.0) + assertNull(r.value()) + r.setCrosshairTimeSec(3.0) + assertEquals(30.0, r.value()) + } + + @Test fun resetClearAndRebindCannotReusePreviousSelection() = runTest { + val r = renderer() + r.setCrosshairTimeSec(1.0) + r.resetPan() + assertNull(r.value()) + r.setCrosshairTimeSec(1.0) + r.clearAllData() + assertNull(r.value()) + r.addSampleSec("s", 1.0, 30.0) + r.setCrosshairTimeSec(1.0) + r.unbindSignal("s") + r.bindSignal("s", "Replacement", AxisPosition.RIGHT_0, 0xFF00FFL) + r.addSampleSec("s", 1.0, 50.0) + assertNull(r.value()) + r.setCrosshairTimeSec(1.0) + assertEquals(50.0, r.value()) + r.setCrosshairTimeSec(null) + assertNull(r.renderSnapshotFlow.value.crosshairTimeSec) + assertNull(r.renderSnapshotFlow.value.seriesList.single().cursorValue) + } + + @Test fun liveFramesKeepValuesWhileScrollingAndMetadataStillUpdatesOnAllAxes() = runTest { + val r = renderer() + r.bindSignal("other", "Other", AxisPosition.RIGHT_0, 0xFF00FFL) + r.addSampleSec("other", 0.0, 100.0) + r.addSampleSec("other", 2.0, 200.0) + r.setSeriesVisible("other", false) + r.setCrosshairTimeSec(1.0) + r.start(backgroundScope) + r.resume() + runCurrent() + r.addSampleSec("s", 2.1, 999.0) + advanceTimeBy(20) + runCurrent() + val live = r.renderSnapshotFlow.value + assertEquals(2.1, live.timeMaxSec) + assertEquals(1.0, live.crosshairTimeSec) + assertEquals(listOf(15.0, 150.0), live.seriesList.map { it.cursorValue }) + r.setSeriesVisible("other", true) + r.setSeriesColor("other", 0xFFFFFFFFL) + val other = r.renderSnapshotFlow.value.seriesList.last() + assertTrue(other.isVisible) + assertEquals(0xFFFFFFFFL, other.color) + assertEquals(150.0, other.cursorValue) + } + + @Test fun ringCaptureHandlesEmptyExactSingleAndWrappedBuffersWithoutClamping() { + val b = TimeSeriesRingBuffer(3) + assertTrue(b.captureAt(1.0).isEmpty()) + b.addSample(1.0, 10.0) + assertEquals(listOf(1.0 to 10.0), b.captureAt(1.0)) + assertTrue(b.captureAt(1.1).isEmpty()) + for (i in 2..4) b.addSample(i.toDouble(), i * 10.0) + val captured = b.captureAt(2.5) + assertEquals(listOf(2.0 to 20.0, 3.0 to 30.0), captured) + assertTrue(b.captureAt(1.0).isEmpty()) + assertTrue(b.captureAt(Double.NaN).isEmpty()) + b.clear() + assertEquals(listOf(2.0 to 20.0, 3.0 to 30.0), captured) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartCursorTooltipTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartCursorTooltipTest.kt new file mode 100644 index 0000000..217ea42 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChartCursorTooltipTest.kt @@ -0,0 +1,49 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.InterpolationMode +import com.dct.app.domain.model.chart.SeriesRenderData +import com.dct.app.presentation.chart.chartCursorTooltipRows +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ChartCursorTooltipTest { + private fun series() = SeriesRenderData( + signalId = "sig_1_1", signalName = "Voltage", axisPosition = AxisPosition.LEFT_0, + color = 0x0000FFL, unit = "V", lineWidthDp = 1f, + interpolationMode = InterpolationMode.LINEAR, isVisible = true, + points = listOf(0.0 to 10.0, 2.0 to 20.0), + latestValue = 20.0, minVisibleValue = 10.0, maxVisibleValue = 20.0, cursorValue = 15.0 + ) + + @Test fun tooltipValuesStayOpaqueWhiteRegardlessOfSignalColor() { + for (color in listOf(0x000000L, 0x0000FFL, 0xFF0000L, 0x00FF00L, 0xFFFFFFFFL)) { + val row = chartCursorTooltipRows(listOf(series().copy(color = color))).single() + assertEquals(0xFFFFFFFF.toInt(), row.valueColorArgb) + } + } + + @Test fun linearShowsCursorValueAndUnitInsteadOfLatestValue() { + val row = chartCursorTooltipRows(listOf(series())).single() + assertEquals("Voltage", row.name) + assertEquals("15.00 V", row.value) + } + + @Test fun displayPointsCannotChangeCapturedCursorValue() { + val signal = series().copy(points = listOf(0.0 to 999.0, 2.0 to -999.0), latestValue = -999.0) + assertEquals("15.00 V", chartCursorTooltipRows(listOf(signal)).single().value) + assertEquals("15.00 V", chartCursorTooltipRows(listOf(signal.copy(points = emptyList()))).single().value) + } + + @Test fun missingAndNonFiniteValuesAreExplicitAndHiddenSignalsAreOmitted() { + assertEquals("N/A", chartCursorTooltipRows(listOf(series().copy(cursorValue = null))).single().value) + assertEquals("N/A", chartCursorTooltipRows(listOf(series().copy(cursorValue = Double.NaN))).single().value) + assertEquals("N/A", chartCursorTooltipRows(listOf(series().copy(cursorValue = Double.POSITIVE_INFINITY))).single().value) + assertTrue(chartCursorTooltipRows(listOf(series().copy(isVisible = false))).isEmpty()) + } + + @Test fun noUnitHasNoTrailingSpaceAndNegativeValuesArePreserved() { + val signal = series().copy(unit = "", cursorValue = -1.25) + assertEquals("-1.25", chartCursorTooltipRows(listOf(signal)).single().value) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt new file mode 100644 index 0000000..bac7e49 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt @@ -0,0 +1,272 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.* +import com.dct.app.domain.model.project.DpcChartState +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ChartNavigationTest { + private fun renderer() = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { + bindSignal("s", "Signal", AxisPosition.LEFT_0, 0x0000FFL) + addSampleSec("s", 1000.0, 1.0) + setTimeWindow(TimeWindowPreset.MIN_1) + } + private fun ChartBatchRenderer.sample(time: Double) = addSampleSec("s", time, 2.0) + private fun ChartBatchRenderer.snapshot() = buildSnapshot(600) + private fun ChartRenderSnapshot.span() = timeMaxSec - timeMinSec + + @Test fun doubleTapRestoresPreNavigationScalePresetAndLatestTrackingWithoutChangingYAxisOrData() { + val r = renderer() + r.setYAxisRange(-5.0, 5.0) + r.zoomTimeWindow(2f, .5) + r.zoomTimeWindow(2f, .2) + r.panTimeWindow(-20.0) + r.setCrosshairTimeSec(900.0) + r.sample(1400.0) + r.resetPan() + val restored = r.snapshot() + assertEquals(300.0, restored.span()) + assertEquals(1400.0, restored.timeMaxSec) + assertEquals(TimeWindowPreset.MIN_1, restored.xAxisPreset) + assertTrue(restored.isXAxisAuto) + assertNull(restored.crosshairTimeSec) + assertEquals(300.0, r.renderSnapshotFlow.value.span()) // Includes paused rendering. + assertEquals(-5.0, restored.axesConfig.getValue(AxisPosition.LEFT_0).minVal) + assertEquals(5.0, restored.axesConfig.getValue(AxisPosition.LEFT_0).maxVal) + assertEquals(0x0000FFL, r.seriesListFlow.value.single().color) + assertEquals(1000.0, r.seriesListFlow.value.single().buffer.getOldestTimestamp()) + r.sample(1401.0) + assertTrue(r.snapshot().timeMaxSec > 1400.0) + } + + @Test fun forwardPanCanPassTheOldRightEdgeAsNewSamplesArriveButNotTheLatestSample() { + val r = renderer() + r.panTimeWindow(-50.0) + assertEquals(950.0, r.snapshot().timeMaxSec) + r.sample(1200.0) + r.panTimeWindow(100.0) + assertEquals(1050.0, r.snapshot().timeMaxSec) + r.panTimeWindow(10000.0) + assertEquals(1200.0, r.snapshot().timeMaxSec) + assertEquals(300.0, r.snapshot().span()) + assertTrue(r.snapshot().isXAxisAuto) + r.sample(1300.0) + assertEquals(1300.0, r.snapshot().timeMaxSec) + r.panTimeWindow(-30.0) + assertEquals(1270.0, r.snapshot().timeMaxSec) + } + + @Test fun forwardPanAfterZoomAlsoUsesCurrentLatestData() { + val r = renderer() + r.zoomTimeWindow(2f, .5) + r.sample(2000.0) + r.panTimeWindow(10000.0) + assertEquals(2000.0, r.snapshot().timeMaxSec) + assertEquals(150.0, r.snapshot().span()) + assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) + r.sample(2001.0) + assertEquals(2001.0, r.snapshot().timeMaxSec) + assertEquals(150.0, r.snapshot().span()) + } + + @Test fun forwardPanAfterZoomOutResumesRollingAtExpandedScale() { + val r = renderer() + r.panTimeWindow(-500.0) + r.zoomTimeWindow(.5f, .5) + assertEquals(600.0, r.snapshot().span()) + assertFalse(r.snapshot().isXAxisAuto) + r.panTimeWindow(10000.0) + assertTrue(r.snapshot().isXAxisAuto) + r.sample(1001.0) + assertEquals(1001.0, r.snapshot().timeMaxSec) + assertEquals(600.0, r.snapshot().span()) + } + + @Test fun zoomOutReachingLatestEdgeResumesWithoutAnotherDrag() { + val r = renderer() + r.panTimeWindow(-50.0) + r.zoomTimeWindow(.5f, .5) + assertEquals(1000.0, r.renderSnapshotFlow.value.timeMaxSec) + assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) + r.sample(1100.0) + assertEquals(1100.0, r.snapshot().timeMaxSec) + assertEquals(600.0, r.snapshot().span()) + } + + @Test fun pinchAnchoredAtLatestEdgeKeepsFollowingForZoomInAndOut() { + val r = renderer() + for ((factor, expectedSpan) in listOf(2f to 150.0, .5f to 300.0, 3f to 100.0)) { + r.zoomTimeWindow(factor, 1.0) + assertTrue(r.snapshot().isXAxisAuto) + assertEquals(expectedSpan, r.snapshot().span(), 1e-8) + val latest = r.snapshot().timeMaxSec + 1.0 + r.sample(latest) + assertEquals(latest, r.snapshot().timeMaxSec) + assertEquals(expectedSpan, r.snapshot().span(), 1e-8) + } + r.resetPan() + assertEquals(300.0, r.snapshot().span()) + assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) + } + + @Test fun draggingAwayStopsFollowingAndReturningResumesWithoutChangingZoom() { + val r = renderer() + r.zoomTimeWindow(2f, 1.0) + r.panTimeWindow(-10.0) + assertFalse(r.renderSnapshotFlow.value.isXAxisAuto) + r.sample(1010.0) + assertEquals(990.0, r.snapshot().timeMaxSec) + r.panTimeWindow(19.0) + assertFalse(r.snapshot().isXAxisAuto) + assertEquals(1009.0, r.snapshot().timeMaxSec) + r.panTimeWindow(1.0) + assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) + r.sample(1011.0) + assertEquals(1011.0, r.snapshot().timeMaxSec) + assertEquals(150.0, r.snapshot().span()) + r.resetPan() + assertEquals(300.0, r.snapshot().span()) + assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) + } + + @Test fun minimumZoomAtEpochTimestampDoesNotSnapHistoryPrematurely() { + val r = renderer() + r.sample(1700000000.0) + r.setTimeWindow(.05) + r.panTimeWindow(-.01) + r.panTimeWindow(.009) + assertFalse(r.snapshot().isXAxisAuto) + r.panTimeWindow(.001) + assertTrue(r.snapshot().isXAxisAuto) + r.sample(1700000000.1) + assertEquals(1700000000.1, r.snapshot().timeMaxSec) + assertEquals(.05, r.snapshot().span(), 1e-6) + } + + @Test fun forwardDragWhileFollowingPreservesFixedPresetAndInitialFutureMargin() { + val r = renderer() + r.clearAllData() + r.sample(2000.0) + val before = r.snapshot() + assertTrue(before.timeMaxSec > 2000.0) + r.panTimeWindow(10.0) + assertEquals(before.timeMaxSec, r.snapshot().timeMaxSec) + assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) + assertTrue(r.snapshot().isXAxisAuto) + } + + @Test fun emptyOrExplicitlyFrozenViewportDoesNotResumeWithoutReachingDataByGesture() { + val r = renderer() + r.setXAxisManualMode() + r.panTimeWindow(0.0) + r.zoomTimeWindow(1f, 1.0) + assertFalse(r.snapshot().isXAxisAuto) + r.clearAllData() + r.zoomTimeWindow(2f, 1.0) + r.panTimeWindow(100.0) + assertFalse(r.snapshot().isXAxisAuto) + assertTrue(r.snapshot().timeMaxSec.isFinite()) + } + + @Test fun resumedCustomScaleIsSavedAndOtherWindowsRemainFrozen() { + val r = renderer() + val other = renderer() + other.panTimeWindow(-50.0) + r.zoomTimeWindow(2f, 1.0) + val state = r.exportWorkspaceState() + assertTrue(state.isXAxisAuto) + assertFalse(state.isAutoTimeWindow) + assertEquals(TimeWindowPreset.MANUAL, state.timeWindowPreset) + assertEquals(150.0, state.timeWindowSec) + r.restoreWorkspaceState(r.seriesListFlow.value, state) + r.sample(2000.0) + other.sample(2000.0) + assertEquals(2000.0, r.snapshot().timeMaxSec) + assertEquals(150.0, r.snapshot().span()) + assertEquals(950.0, other.snapshot().timeMaxSec) + assertFalse(other.snapshot().isXAxisAuto) + } + + @Test fun doubleTapRestoresAutoAndIncludesDataReceivedWhileBrowsingHistory() { + val r = renderer() + r.setTimeWindow(TimeWindowPreset.AUTO) + r.sample(1100.0) + r.zoomTimeWindow(2f, .5) + r.panTimeWindow(-10.0) + r.sample(2000.0) + r.resetPan() + val restored = r.snapshot() + assertEquals(TimeWindowPreset.AUTO, restored.xAxisPreset) + assertEquals(1000.0, restored.timeMinSec) + assertTrue(restored.timeMaxSec > 2000.0) + assertTrue(r.exportWorkspaceState().isAutoTimeWindow) + } + + @Test fun doubleTapPreservesCustomDurationAndSixIntervalResetScale() { + val r = renderer() + r.setTimeWindow(17.5) + r.zoomTimeWindow(2f, .5) + r.resetPan() + assertEquals(17.5, r.snapshot().span()) + r.setTimeWindow(TimeWindowPreset.MIN_1) + r.clearAllData() + r.sample(2000.0) + assertEquals(360.0, r.snapshot().span()) + r.zoomTimeWindow(3f, .5) + r.resetPan() + assertEquals(360.0, r.snapshot().span()) + assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) + } + + @Test fun selectingAnotherPresetReplacesPreviousResetTarget() { + val r = renderer() + r.zoomTimeWindow(2f, .5) + r.setTimeWindow(TimeWindowPreset.SEC_10) + r.panTimeWindow(-10.0) + r.zoomTimeWindow(2f, .5) + r.resetPan() + assertEquals(40.0, r.snapshot().span()) + assertEquals(TimeWindowPreset.SEC_10, r.snapshot().xAxisPreset) + } + + @Test fun stationaryPinchAndZeroPanDoNotEnterManualMode() { + val r = renderer() + r.zoomTimeWindow(1f, .5) + r.panTimeWindow(0.0) + r.panTimeWindow(Double.NaN) + assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) + assertTrue(r.snapshot().isXAxisAuto) + } + + @Test fun restoredManualRangeCanReturnToLatestWithoutAnOldNavigationTarget() { + val r = renderer() + r.zoomTimeWindow(3f, .5) + val signal = r.seriesListFlow.value.single() + r.restoreWorkspaceState(listOf(signal), DpcChartState(timeWindowSec = 25.0, + timeWindowPreset = TimeWindowPreset.MANUAL, isXAxisAuto = false)) + r.sample(5000.0) + r.resetPan() + assertEquals(25.0, r.snapshot().span()) + assertEquals(5000.0, r.snapshot().timeMaxSec) + assertTrue(r.snapshot().isXAxisAuto) + } + + @Test fun resetWithoutSamplesIsFiniteAndIndependentBetweenWindows() { + val a = renderer() + val b = renderer() + a.zoomTimeWindow(2f, .5) + b.setTimeWindow(TimeWindowPreset.SEC_10) + b.zoomTimeWindow(4f, .5) + a.resetPan() + assertEquals(300.0, a.snapshot().span()) + assertEquals(10.0, b.snapshot().span()) + a.clearAllData() + a.zoomTimeWindow(2f, .5) + a.resetPan() + assertEquals(360.0, a.snapshot().span(), .001) + assertTrue(a.snapshot().timeMaxSec.isFinite()) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt new file mode 100644 index 0000000..00462f5 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt @@ -0,0 +1,142 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ChartStoppedNavigationTest { + private fun TestScope.renderer() = ChartBatchRenderer( + TestDispatcherProvider(StandardTestDispatcher(testScheduler)) + ).apply { + bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF0000L) + for (second in 900..1000) addSampleSec("s", second.toDouble(), second.toDouble()) + setTimeWindow(30.0) + } + + // Assert the flow the UI collects, not buildSnapshot(), which would mask the missing render. + private fun assertRight(r: ChartBatchRenderer, expected: Double, span: Double = 30.0) { + val frame = r.renderSnapshotFlow.value + assertEquals(expected, frame.timeMaxSec, 1e-8) + assertEquals(span, frame.timeMaxSec - frame.timeMinSec, 1e-8) + assertTrue(frame.seriesList.single().points.isNotEmpty()) + } + + @Test fun stopThenRepeatedDragsPublishBothDirectionsWithoutNewSamplesOrRenderLoop() = runTest { + val r = renderer() + val job = r.start(backgroundScope) + r.resume() + runCurrent() + r.stop() // Same renderer lifecycle used for STOP ACK and connection loss. + assertTrue(job.isCancelled) + r.panTimeWindow(-10.0) + assertRight(r, 990.0) + assertTrue(r.renderSnapshotFlow.value.isPaused) + r.panTimeWindow(-10.0) + assertRight(r, 980.0) + r.panTimeWindow(5.0) + assertRight(r, 985.0) + r.panTimeWindow(15.0) + assertRight(r, 1000.0) + assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) + r.panTimeWindow(-5.0) + assertRight(r, 995.0) + assertTrue(job.isCancelled) // Navigation must not restart measurement/render jobs. + assertEquals(900.0, r.seriesListFlow.value.single().buffer.getOldestTimestamp()) + assertEquals(1000.0, r.seriesListFlow.value.single().buffer.getLatestTimestamp()) + } + + @Test fun stoppingAnAlreadyManualViewportDoesNotLoseFurtherDragUpdates() = runTest { + val r = renderer() + r.start(backgroundScope) + r.panTimeWindow(-20.0) + runCurrent() + assertRight(r, 980.0) + r.stop() + r.panTimeWindow(5.0) + assertRight(r, 985.0) + r.panTimeWindow(-10.0) + assertRight(r, 975.0) + assertFalse(r.renderSnapshotFlow.value.isXAxisAuto) + } + + @Test fun cancelledJobAndNeverStartedRendererAlsoPublishNavigation() = runTest { + val r = renderer() + r.panTimeWindow(-10.0) + assertRight(r, 990.0) + val job = r.start(backgroundScope) + runCurrent() + job.cancel() // Non-null but inactive job must not count as a rendering consumer. + r.panTimeWindow(-5.0) + assertRight(r, 985.0) + r.panTimeWindow(10.0) + assertRight(r, 995.0) + } + + @Test fun pauseWithAnActiveLoopStillRendersDirtyNavigationAtNormalCadence() = runTest { + val r = renderer() + val job = r.start(backgroundScope) + r.resume() + runCurrent() + r.pause() + r.panTimeWindow(-10.0) + advanceTimeBy(20) + runCurrent() + assertRight(r, 990.0) + assertTrue(r.renderSnapshotFlow.value.isPaused) + r.panTimeWindow(5.0) + advanceTimeBy(20) + runCurrent() + assertRight(r, 995.0) + assertTrue(job.isActive) + } + + @Test fun stoppedZoomAndPanKeepScaleAndAllowLiveFollowAfterMeasurementResumes() = runTest { + val r = renderer() + r.start(backgroundScope) + r.resume() + runCurrent() + r.stop() + r.zoomTimeWindow(2f, 1.0) + r.panTimeWindow(-10.0) + assertRight(r, 990.0, 15.0) + r.panTimeWindow(10.0) + assertRight(r, 1000.0, 15.0) + r.resume() + r.start(backgroundScope) + r.addSampleSec("s", 1001.0, 1001.0) + runCurrent() + assertRight(r, 1001.0, 15.0) + r.stop() + r.resetPan() + assertRight(r, 1001.0, 30.0) + } + + @Test fun stoppedWindowsKeepIndependentOffsetsAndAxisSettings() = runTest { + val a = renderer() + val b = renderer() + a.setYAxisRange(-50.0, 2000.0) + a.start(backgroundScope) + b.start(backgroundScope) + runCurrent() + a.stop() + b.stop() + a.panTimeWindow(-10.0) + b.panTimeWindow(-20.0) + assertRight(a, 990.0) + assertRight(b, 980.0) + a.panTimeWindow(5.0) + assertRight(a, 995.0) + assertRight(b, 980.0) + val axis = a.renderSnapshotFlow.value.axesConfig.getValue(AxisPosition.LEFT_0) + assertFalse(axis.isAuto) + assertEquals(-50.0, axis.minVal) + assertEquals(2000.0, axis.maxVal) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt new file mode 100644 index 0000000..5c9edf2 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt @@ -0,0 +1,242 @@ +package com.dct.app.tests + +import com.dct.app.data.repository.DpcWorkspaceJson +import com.dct.app.domain.model.chart.* +import com.dct.app.domain.model.project.* +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.ZoneId + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ChartXAxisParityTest { + private val utc = ZoneId.of("UTC") + private fun renderer() = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { + bindSignal("s", "Signal", AxisPosition.LEFT_0, 0xFF000000L) + } + private fun ChartBatchRenderer.sample(t: Double) = addSampleSec("s", t, t) + private fun span(s: ChartRenderSnapshot) = s.timeMaxSec - s.timeMinSec + + @Test fun fixedMenuValuesAreTickIntervalsAndUsePcMenuSpans() { + val cases = listOf(TimeWindowPreset.SEC_10 to 40.0, TimeWindowPreset.SEC_30 to 150.0, + TimeWindowPreset.MIN_1 to 300.0, TimeWindowPreset.MIN_5 to 1200.0, TimeWindowPreset.MIN_10 to 2400.0) + for ((preset, expected) in cases) { + val r = renderer() + r.sample(10000.0) + r.setTimeWindow(preset) + val s = r.buildSnapshot(600) + assertEquals(expected, span(s), 1e-8) + assertEquals(10000.0, s.timeMaxSec, 1e-8) + for (width in listOf(80f, 600f, 2400f)) { + val ticks = ChartAxisTicks.time(s.timeMinSec, s.timeMaxSec, width, s.xAxisPreset, zone = utc) + assertEquals(preset.durationSec, ticks.interval.seconds, 1e-8) + assertTrue(ticks.values.size >= 4) + ticks.values.zipWithNext().forEach { (a, b) -> assertEquals(preset.durationSec, b - a, 1e-8) } + } + } + } + + @Test fun autoUsesPc60PixelRuleAndDoesNotCapAtEightIntervals() { + for ((span, step) in listOf(10.0 to 1.0, 30.0 to 5.0, 60.0 to 10.0, 300.0 to 30.0, 600.0 to 120.0)) { + assertEquals(step, ChartAxisTicks.time(0.0, span, 600f, zone = utc).interval.seconds) + } + val wide = ChartAxisTicks.time(0.0, 60.0, 1200f, zone = utc) + assertEquals(5.0, wide.interval.seconds) + assertEquals(13, wide.values.size) + } + + @Test fun transformHelperAndProductionTicksUseTheSameAlgorithm() { + assertEquals(ChartAxisTicks.time(1000.0, 1300.0, 600f).values, + MultiAxisCoordinateTransformer.generateTimeTicks(1000.0, 1300.0, 600f)) + } + + @Test fun labelsAdaptToSecondsMinutesHoursAndMilliseconds() { + val t = Instant.parse("2026-09-03T12:34:56Z").epochSecond.toDouble() + assertEquals("12:34:56", ChartAxisTicks.time(t, t + 40, 600f, TimeWindowPreset.SEC_10).label(t, utc)) + assertEquals("12:34", ChartAxisTicks.time(t, t + 300, 600f, TimeWindowPreset.MIN_1).label(t, utc)) + val hourly = ChartAxisTicks.time(t, t + 86400, 600f) + assertEquals("12:00", hourly.label(t, utc)) + val zoom = ChartAxisTicks.time(t + .1, t + .15, 600f, zone = utc) + assertTrue(zoom.values.size > 1) + assertEquals(zoom.values.size, zoom.values.map { zoom.label(it, utc) }.distinct().size) + assertTrue(zoom.values.all { it >= t + .1 && it <= t + .15 }) + } + + @Test fun longRangesUseCalendarTicksInsteadOfCappingAtTenMinutes() { + val start = Instant.parse("2026-01-01T00:00:00Z").epochSecond.toDouble() + val end = Instant.parse("2027-01-01T00:00:00Z").epochSecond.toDouble() + val ticks = ChartAxisTicks.time(start, end, 600f, zone = utc) + assertEquals(ChartAxisTicks.Unit.MONTH, ticks.interval.unit) + assertEquals(listOf("Jan 2026", "Mar 2026", "May 2026", "Jul 2026", "Sep 2026", "Nov 2026", "Jan 2027"), + ticks.values.map { ticks.label(it, utc) }) + } + + @Test fun hourlyTicksAlignToLocalClockEvenWithHalfHourTimezone() { + val zone = ZoneId.of("Asia/Kolkata") + val ticks = ChartAxisTicks.time(1700000000.0, 1700086400.0, 600f, zone = zone) + assertTrue(ticks.values.isNotEmpty()) + ticks.values.forEach { assertEquals(0, Instant.ofEpochSecond(it.toLong()).atZone(zone).minute) } + } + + @Test fun numericCompensationTicksUseRoundValuesAndFollowWidth() { + val ticks = ChartAxisTicks.numeric(0.0, 100.0, 300f) + assertEquals((0..10).map { it * 10.0 }, ticks.values) + assertEquals(listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"), ticks.values.map(ticks::label)) + assertTrue(ChartAxisTicks.numeric(0.0, 100.0, 90f).step > ticks.step) + } + + @Test fun numericTicksHandleNegativeAndTinyRangesWithoutRepeatedLabels() { + for ((min, max) in listOf(-.03 to .03, .000001 to .000002, -100.0 to -10.0)) { + val ticks = ChartAxisTicks.numeric(min, max, 300f) + assertTrue(ticks.values.isNotEmpty()) + assertEquals(ticks.values.size, ticks.values.map(ticks::label).distinct().size) + assertFalse(ticks.values.map(ticks::label).contains("-0")) + } + } + + @Test fun invalidOrCollapsedBoundsNeverGenerateUnboundedTicks() { + for ((min, max, width) in listOf(Triple(0.0, 1.0, 0f), Triple(1.0, 1.0, 600f), + Triple(Double.NaN, 2.0, 100f), Triple(0.0, Double.POSITIVE_INFINITY, 100f))) { + assertTrue(ChartAxisTicks.time(min, max, width).values.isEmpty()) + assertTrue(ChartAxisTicks.numeric(min, max, width).values.isEmpty()) + } + assertTrue(ChartAxisTicks.time(0.0, 86400.0, 1f).values.size <= 3) + } + + @Test fun labelPlacementKeepsLabelsInsidePlotWithoutOverlaps() { + val centers = listOf(50f, 80f, 110f, 140f, 170f, 200f) + val widths = List(centers.size) { 55f } + val placements = placeAxisLabels(centers, widths, 50f, 200f) + assertTrue(placements.size < centers.size) + placements.forEach { assertTrue(it.left >= 50 && it.left + widths[it.index] <= 200) } + placements.zipWithNext().forEach { (a, b) -> assertTrue(a.left + widths[a.index] + 4 <= b.left) } + assertTrue(placeAxisLabels(listOf(10f), listOf(100f), 0f, 20f).isEmpty()) + } + + @Test fun fixedRangeFillsThenContinuouslyFollowsWithoutBlankTick() { + val r = renderer() + r.setTimeWindow(TimeWindowPreset.SEC_10) + r.sample(1000.0) + val first = r.buildSnapshot(600) + assertEquals(1000.0, first.timeMinSec) + assertEquals(1040.0, first.timeMaxSec) + r.sample(1005.0) + assertEquals(first.timeMaxSec, r.buildSnapshot(600).timeMaxSec) + r.sample(1040.0) + val moved = r.buildSnapshot(600) + assertEquals(1000.0, moved.timeMinSec) + assertEquals(1040.0, moved.timeMaxSec) + r.sample(1040.033) + val rolling = r.buildSnapshot(600) + assertEquals(1000.033, rolling.timeMinSec, 1e-8) + assertEquals(1040.033, rolling.timeMaxSec, 1e-8) + r.sample(5000.0) + val gap = r.buildSnapshot(600) + assertEquals(40.0, span(gap)) + assertEquals(5000.0, gap.timeMaxSec) + } + + @Test fun autoIncludesSingleSampleAndFutureMarginWithoutDegenerateRange() { + val r = renderer() + r.setTimeWindow(TimeWindowPreset.AUTO) + r.sample(1000.0) + val first = r.buildSnapshot(600) + assertEquals(1000.0, first.timeMinSec) + assertTrue(first.timeMaxSec > 1000) + r.sample(1000.01) + assertEquals(first.timeMaxSec, r.buildSnapshot(600).timeMaxSec) + r.sample(1100.0) + val next = r.buildSnapshot(600) + assertEquals(1000.0, next.timeMinSec) + assertTrue(next.timeMaxSec > 1100) + } + + @Test fun manualAndPanDoNotFollowNewSamples() { + val r = renderer() + r.sample(1000.0) + r.setTimeWindow(TimeWindowPreset.SEC_10) + r.setXAxisManualMode() + val before = r.buildSnapshot(600) + r.sample(1100.0) + assertEquals(before.timeMaxSec, r.buildSnapshot(600).timeMaxSec) + r.panTimeWindow(-5.0) + assertEquals(before.timeMinSec - 5, r.buildSnapshot(600).timeMinSec) + r.resetPan() + assertEquals(1100.0, r.buildSnapshot(600).timeMaxSec) + assertEquals(span(before), span(r.buildSnapshot(600))) + assertTrue(r.buildSnapshot(600).isXAxisAuto) + } + + @Test fun zoomPreservesAnchorAndFreezesManualRange() { + val r = renderer() + r.sample(1000.0) + r.setTimeWindow(TimeWindowPreset.MIN_1) + val before = r.buildSnapshot(600) + val anchor = before.timeMinSec + span(before) * .25 + r.zoomTimeWindow(2f, .25) + val zoom = r.buildSnapshot(600) + assertEquals(150.0, span(zoom)) + assertEquals(anchor, zoom.timeMinSec + span(zoom) * .25) + r.sample(2000.0) + assertEquals(zoom.timeMinSec, r.buildSnapshot(600).timeMinSec) + assertEquals(TimeWindowPreset.MANUAL, zoom.xAxisPreset) + } + + @Test fun clearUsesPcSixTickResetSpanWithoutOldTimestamps() { + for (preset in TimeWindowPreset.entries.filter { it.tickIntervalSec != null }) { + val r = renderer() + r.sample(1000.0) + r.setTimeWindow(preset) + r.clearAllData() + r.sample(2000.0) + val cleared = r.buildSnapshot(600) + assertEquals(preset.durationSec * 6, span(cleared)) + assertEquals(2000.0, cleared.timeMinSec) + assertEquals(1, cleared.seriesList.single().points.size) + } + } + + @Test fun projectRoundTripPreservesNewPresetRangeAndMigratesOldRange() { + val r = renderer() + r.setTimeWindow(TimeWindowPreset.MIN_1) + val workspace = DpcWorkspaceState("p", listOf(DpcPageState("p", "Page", windows = listOf( + DpcWindowState("w", "GRAPH", "Graph", chart = r.exportWorkspaceState()) + )))) + val decoded = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!! + val saved = decoded.pages.single().windows.single().chart!! + val restored = renderer() + restored.restoreWorkspaceState(emptyList(), saved) + assertEquals(300.0, restored.exportWorkspaceState().timeWindowSec) + restored.restoreWorkspaceState(emptyList(), saved.copy(timeWindowSec = 60.0)) + assertEquals(300.0, restored.exportWorkspaceState().timeWindowSec) + restored.restoreWorkspaceState(emptyList(), saved.copy(timeWindowSec = 60.0, + timeWindowPreset = TimeWindowPreset.MANUAL, isXAxisAuto = false)) + assertEquals(60.0, restored.exportWorkspaceState().timeWindowSec) + } + + @Test fun independentGraphControllersDoNotShareRangeOrPreset() { + val a = renderer() + val b = renderer() + a.sample(1000.0); b.sample(1000.0) + a.setTimeWindow(TimeWindowPreset.SEC_10) + b.setTimeWindow(TimeWindowPreset.MIN_5) + a.sample(1500.0) + a.buildSnapshot(600) + assertEquals(1200.0, span(b.buildSnapshot(600))) + assertEquals(1000.0, b.buildSnapshot(600).timeMaxSec) + } + + @Test fun explicitDurationApiRemainsARollingSpanNotAFixedTickPreset() { + val r = renderer() + r.setTimeWindow(30.0) + r.sample(1000.0) + val first = r.buildSnapshot(600) + assertEquals(970.0, first.timeMinSec) + assertEquals(1000.0, first.timeMaxSec) + r.sample(1001.0) + assertEquals(1001.0, r.buildSnapshot(600).timeMaxSec) + assertEquals(30.0, span(r.buildSnapshot(600))) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChecksumCalculatorTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChecksumCalculatorTest.kt new file mode 100644 index 0000000..003b6aa --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ChecksumCalculatorTest.kt @@ -0,0 +1,108 @@ +package com.dct.app.tests + +import com.google.common.truth.Truth.assertThat +import com.dct.app.data.protocol.ChecksumCalculator +import com.dct.app.data.protocol.DefaultChecksumCalculator +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +@DisplayName("ChecksumCalculator (8-bit NOT Checksum) 단위 테스트") +class ChecksumCalculatorTest { + + private lateinit var calculator: ChecksumCalculator + + @BeforeEach + fun setUp() { + calculator = DefaultChecksumCalculator() + } + + @Test + @DisplayName("C++ 레퍼런스 패킷(ENABLE ACK: 0xA1 0x00 0x01 0x80)의 체크섬은 0xDD이어야 함") + fun testCppReferenceEnableAckChecksum() { + // C++ DCTTestServerDlg.cpp: char buffer[] = { 0xA1, 0x00, 0x01, 0x80, 0xDD, 0x7E }; + val data = byteArrayOf(0xA1.toByte(), 0x00, 0x01, 0x80.toByte()) + val checksum = calculator.calculate(data, 0, data.size) + + // 0xA1(161) + 0x00(0) + 0x01(1) + 0x80(128) = 290 = 0x122 -> 8비트 누적합 0x22 + // 0x22 xor 0xFF = 0xDD (-35 / 221) + assertThat(checksum).isEqualTo(0xDD.toByte()) + + // 완전한 프레임 검증 + val fullFrame = byteArrayOf(0xA1.toByte(), 0x00, 0x01, 0x80.toByte(), 0xDD.toByte(), 0x7E) + assertThat(calculator.verify(fullFrame)).isTrue() + } + + @Test + @DisplayName("모든 바이트가 0x00인 경우 체크섬은 0xFF이어야 함") + fun testAllZerosChecksum() { + val zeros = ByteArray(10) { 0x00 } + val checksum = calculator.calculate(zeros) + assertThat(checksum).isEqualTo(0xFF.toByte()) + } + + @Test + @DisplayName("모든 바이트가 0xFF인 경우 체크섬 누적 오버플로우 정상 처리") + fun testAll0xFFChecksum() { + // 0xFF * 256의 누적합은 8비트에서 0이 되므로 NOT 취하면 0xFF + val data = ByteArray(256) { 0xFF.toByte() } + val checksum = calculator.calculate(data) + assertThat(checksum).isEqualTo(0xFF.toByte()) + } + + @Test + @DisplayName("대용량 4096바이트 데이터의 체크섬 누적 연산이 정확해야 함") + fun testLargeDataChecksum() { + val data = ByteArray(4096) { (it and 0xFF).toByte() } + var expectedSum = 0 + for (b in data) { + expectedSum = (expectedSum + (b.toInt() and 0xFF)) and 0xFF + } + val expectedChecksum = (expectedSum xor 0xFF).toByte() + + val actualChecksum = calculator.calculate(data) + assertThat(actualChecksum).isEqualTo(expectedChecksum) + } + + @Test + @DisplayName("체크섬 1바이트 변조 시 verify는 false를 반환해야 함") + fun testCorruptedChecksumVerification() { + val fullFrame = byteArrayOf(0xA1.toByte(), 0x00, 0x01, 0x80.toByte(), 0xDD.toByte(), 0x7E) + + // 체크섬 바이트 변조 (0xDD -> 0xDC) + fullFrame[4] = 0xDC.toByte() + assertThat(calculator.verify(fullFrame)).isFalse() + + // 페이로드 데이터 변조 + fullFrame[4] = 0xDD.toByte() + fullFrame[3] = 0x81.toByte() + assertThat(calculator.verify(fullFrame)).isFalse() + } + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2, 3, 4, 5]) + @DisplayName("최소 프레임 길이(6바이트) 미만인 경우 verify는 false를 반환해야 함") + fun testUnderflowFrameLengthVerification(length: Int) { + val smallFrame = ByteArray(length) + assertThat(calculator.verify(smallFrame)).isFalse() + } + + @Test + @DisplayName("유효하지 않은 오프셋 또는 길이 입력 시 IllegalArgumentException 발생") + fun testInvalidOffsetAndLengthThrowsException() { + val data = ByteArray(10) + + assertThrows { + calculator.calculate(data, offset = -1, length = 5) + } + assertThrows { + calculator.calculate(data, offset = 0, length = -1) + } + assertThrows { + calculator.calculate(data, offset = 8, length = 5) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/CompensationLayoutTest.kt b/Android/app/src/test/java/com/dct/app/tests/CompensationLayoutTest.kt new file mode 100644 index 0000000..5988a27 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/CompensationLayoutTest.kt @@ -0,0 +1,128 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.MultiAxisCoordinateTransformer +import com.dct.app.domain.model.mdb.* +import com.dct.app.presentation.signaltree.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class CompensationLayoutTest { + @Test fun physicalValuePreservesUpToFourDecimalPlacesForBothTypes() { + val expected = mapOf(12.0 to "12", 12.3 to "12.3", 12.34 to "12.34", + 12.345 to "12.345", 12.34567 to "12.3457", -0.00001 to "0", -12.3456 to "-12.3456") + for ((value, text) in expected) { + assertEquals(text, formatCompensationPhysicalValue(value)) + for (typeA in listOf(true, false)) { + assertEquals(text, compensationPointColumns(typeA).single { it.title == "PHY" } + .text(MapPoint(0, 0, 0, value))) + } + } + assertEquals("N/A", formatCompensationPhysicalValue(Double.NaN)) + } + + @Test fun coefficientPrecisionMatchesPcTypeAAndTypeB() { + for (typeA in listOf(true, false)) { + val columns = compensationMapColumns(typeA) + val sample = map().copy(cvsA = 0.12345, cvsB = -1.23456) + assertEquals(if (typeA) "0.1235" else "0.123", columns.single { it.title == "CVS_A" }.text(sample)) + assertEquals(if (typeA) "-1.2346" else "-1.235", columns.single { it.title == "CVS_B" }.text(sample)) + assertEquals(if (typeA) "1.0000" else "1.000", formatCompensationCoefficient(1.0, typeA)) + } + } + + private fun map(type: CompensationMapType = CompensationMapType.TYPE_A) = CompensationMap( + type, 1, "Y", 0, MdbAttrType.ATTR_3_INT16, 0.0, 100.0, + "X", 0, MdbAttrType.ATTR_3_INT16, 1.0, 0.0, + points = listOf(MapPoint(0, 10, -20, -30.0), MapPoint(1, 20, 200, 150.0)) + ) + + @Test fun typeARangeIncludesDataOutsideConfiguredLimitsWithoutChangingSamples() { + val original = map() + val curve = compensationCurveGeometry(original)!! + assertEquals(-20.0, curve.minY) + assertEquals(200.0, curve.maxY) + assertEquals(listOf(-30.0 to -20.0, 150.0 to 200.0), curve.points) + assertEquals(100.0, original.yMax) + assertEquals(200, original.points.last().data) + } + + @Test fun typeBUsesBkpAndPhyAndIncludesConfiguredRangeAndZero() { + val original = map(CompensationMapType.TYPE_B).copy(yMin = -100.0, yMax = 300.0) + val curve = compensationCurveGeometry(original)!! + assertEquals(listOf(10.0 to -30.0, 20.0 to 150.0), curve.points) + assertEquals(-100.0, curve.minY) + assertEquals(300.0, curve.maxY) + val positive = compensationCurveGeometry(original.copy(yMin = 10.0, yMax = 20.0, + points = listOf(MapPoint(0, 1, 0, 15.0))))!! + assertEquals(0.0, positive.minY) + } + + @Test fun equalValuesHaveNonzeroDisplayRanges() { + val curve = compensationCurveGeometry(map().copy(yMin = 0.0, yMax = 0.0, + points = listOf(MapPoint(0, 0, 0, 0.0))))!! + assertEquals(-10.0, curve.minX) + assertEquals(10.0, curve.maxX) + assertEquals(-10.0, curve.minY) + assertEquals(10.0, curve.maxY) + } + + @Test fun invalidDataDoesNotPoisonRangeAndRemainsAGap() { + val original = map().copy(yMin = Double.NaN, yMax = Double.POSITIVE_INFINITY, + points = listOf(MapPoint(0, 1, 10, 1.0), MapPoint(1, 2, 20, Double.NaN), MapPoint(2, 3, 30, 3.0))) + val curve = compensationCurveGeometry(original)!! + assertEquals(1.0, curve.minX) + assertEquals(3.0, curve.maxX) + assertTrue(curve.points[1].first.isNaN()) + assertNull(compensationCurveGeometry(original.copy(points = listOf(original.points[1])))) + assertNull(compensationCurveGeometry(original.copy(points = emptyList()))) + } + + @Test fun compensationBoundsStayInsideCanvasAndRejectTooSmallAreas() { + assertNull(compensationPlotBounds(81f, 300f)) + assertNull(compensationPlotBounds(300f, 63f)) + assertNull(compensationPlotBounds(Float.NaN, 300f)) + val bounds = compensationPlotBounds(300f, 200f)!! + assertEquals(60f, bounds.left) + assertEquals(288f, bounds.right) + assertEquals(162f, bounds.bottom) + assertTrue(bounds.isDrawable) + } + + @Test fun timeGraphBoundsNeverExceedCanvasEvenWithManyAxesAndLargeLegend() { + for (width in listOf(0f, 20f, 120f, 300f, 1000f)) { + for (height in listOf(0f, 10f, 50f, 300f)) { + for (legend in 0..3) { + val b = MultiAxisCoordinateTransformer.calculatePlotBounds(width, height, 4, + rightAxisCount = 4, legendPosition = legend, legendWidthPx = 600f, seriesCount = 20) + assertTrue(b.left in 0f..width && b.right in b.left..width) + assertTrue(b.top in 0f..height && b.bottom in b.top..height) + } + } + } + assertFalse(MultiAxisCoordinateTransformer.calculatePlotBounds(0f, 0f, 1).isDrawable) + } + + @Test fun columnDefinitionsPreserveTypeSpecificOrderAndValues() { + assertEquals(listOf("IDX", "PHY", "BKP", "Data"), compensationPointColumns(true).map { it.title }) + assertEquals(listOf("IDX", "BKP", "Data", "PHY"), compensationPointColumns(false).map { it.title }) + assertEquals(listOf("0", "-30", "10", "-20"), compensationPointColumns(true).map { it.text(map().points[0]) }) + for (typeA in listOf(true, false)) { + val columns = compensationMapColumns(typeA) + assertEquals(11, columns.size) + assertEquals("DESCRIPTION", columns.last().title) + assertEquals(11, columns.map { it.title }.distinct().size) + assertEquals(if (typeA) "XNAME" else "CVS_A", columns[5].title) + } + } + + @Test fun pointAndMetadataWidthsAreIsolatedAndRestored() { + var saved = emptyMap() + val widths = WindowColumnWidths(WindowUiStore(emptyMap()) { saved = it }) + widths["comp:map:YNAME"] = androidx.compose.ui.unit.Dp(240f) + widths["comp:point:PHY"] = androidx.compose.ui.unit.Dp(110f) + val restored = WindowColumnWidths(WindowUiStore(saved) {}) + assertEquals(240f, restored["comp:map:YNAME"]!!.value) + assertEquals(110f, restored["comp:point:PHY"]!!.value) + assertNull(restored["comp:map:PHY"]) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ConstantPresentationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ConstantPresentationTest.kt new file mode 100644 index 0000000..8f0728f --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/ConstantPresentationTest.kt @@ -0,0 +1,50 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.mdb.* +import com.dct.app.presentation.signaltree.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ConstantPresentationTest { + private val constant = MdbConstant(1, "Torque", 350.0, 0x1000, 0.0, 500.0, MdbAttrType.ATTR_2_UINT16) + + @Test fun numbersAreIntegersIncludingUint32WithoutExponentOrNegativeZero() { + assertEquals("350", formatConstantInteger(350.0)) + assertEquals("4294967295", formatConstantInteger(4294967295.0)) + assertEquals("-2147483648", formatConstantInteger(-2147483648.0)) + assertEquals("0", formatConstantInteger(-0.0)) + for (bad in listOf(Double.NaN, Double.POSITIVE_INFINITY, 1.5)) assertEquals("N/A", formatConstantInteger(bad)) + } + + @Test fun editorRejectsFractionsNonFiniteAndMdbRangeViolations() { + for (text in listOf("", "abc", "NaN", "Infinity", "1.5", "501", "-1")) assertNotNull(constantInputError(text, constant), text) + for (text in listOf("0", "350", "500", "350.0")) assertNull(constantInputError(text, constant), text) + } + + @Test fun allSignedAndUnsignedAttributeBoundariesAreChecked() { + for (attr in MdbAttrType.entries) { + val bits = attr.byteSize * 8 + val min = if (attr.isSigned) -(1L shl (bits - 1)) else 0L + val max = if (attr.isSigned) (1L shl (bits - 1)) - 1 else (1L shl bits) - 1 + val c = constant.copy(attr = attr, min = -1e15, max = 1e15) + assertNull(constantInputError(min.toString(), c)) + assertNull(constantInputError(max.toString(), c)) + assertNotNull(constantInputError((min - 1).toString(), c)) + assertNotNull(constantInputError((max + 1).toString(), c)) + } + } + + @Test fun checkedFilterUsesStableIdsAndDoesNotLoseChecksWhenSearchChanges() { + val constants = listOf(constant, constant.copy(idx = 2, name = "RPM")) + assertEquals(listOf(constant), filterConstants(constants, "torQUE", emptySet(), false)) + assertEquals(listOf(constants[1]), filterConstants(constants, "torQUE", setOf(2L), true)) + assertTrue(filterConstants(constants, "", emptySet(), true).isEmpty()) + assertEquals(constants, filterConstants(constants, "", setOf(2L), false)) + } + + @Test fun invalidMetadataRangeDoesNotPermitWrites() { + for (c in listOf(constant.copy(min = Double.NaN), constant.copy(max = Double.POSITIVE_INFINITY), constant.copy(min = 600.0))) { + assertNotNull(constantInputError("350", c)) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt b/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt new file mode 100644 index 0000000..658bfdd --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt @@ -0,0 +1,159 @@ +package com.dct.app.tests + +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.protocol.DctCanMessageConfiguration +import com.dct.app.domain.model.project.createDefaultSensorModules +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +@DisplayName("PC DCT 프로토콜 요청 바이트 정합성") +class DctProtocolRequestsTest { + @Test + fun `P3 maintenance request bytes match PC protocol`() { + assertThat(DctProtocolRequests.savedLogInfo(0x1234).payload) + .isEqualTo(byteArrayOf(0x02, 0x12, 0x34, 0x01)) + assertThat(DctProtocolRequests.savedLogChunk(0x1234, 0x01020304, 4096).payload) + .isEqualTo(byteArrayOf(0x02, 0x12, 0x34, 0x01, 0x02, 0x03, 0x04, 0x10, 0x00)) + assertThat(DctProtocolRequests.firmwareChunk(2, 0x0123, true, byteArrayOf(0x55, 0x66)).payload) + .isEqualTo(byteArrayOf(0x05, 0x02, 0x01, 0x23, 0x02, 0x00, 0x02, 0x55, 0x66)) + assertThat(DctProtocolRequests.firmwareKeySuccess(byteArrayOf(0x11, 0x22), 0x01020304, 99).payload) + .isEqualTo(byteArrayOf(0x02, 0x01, 0x02, 0x11, 0x22, 0x01, 0x02, 0x03, 0x04, 99)) + assertThat(DctProtocolRequests.firmwareKeyFailure().payload) + .isEqualTo(byteArrayOf(0x02, 0x02, 0x00)) + } + private val codec = DctPacketCodec() + + @Test + fun `핵심 제어 명령은 PC 골든 프레임과 일치한다`() { + assertThat(codec.encode(DctProtocolRequests.enable())).isEqualTo(hex("A1 00 01 00 5D 7E")) + assertThat(codec.encode(DctProtocolRequests.version())).isEqualTo(hex("A1 00 01 01 5C 7E")) + assertThat(codec.encode(DctProtocolRequests.hardwareId())).isEqualTo(hex("A1 00 01 03 5A 7E")) + assertThat(codec.encode(DctProtocolRequests.startAll())).isEqualTo(hex("A1 00 02 20 01 3B 7E")) + assertThat(codec.encode(DctProtocolRequests.stop())).isEqualTo(hex("A1 00 01 21 3C 7E")) + assertThat(codec.encode(DctProtocolRequests.canStreamAck())).isEqualTo(hex("A1 00 02 23 01 38 7E")) + assertThat(codec.encode(DctProtocolRequests.smStreamAck())).isEqualTo(hex("A1 00 02 24 01 37 7E")) + assertThat(codec.encode(DctProtocolRequests.xcpStreamAck())).isEqualTo(hex("A1 00 02 50 01 0B 7E")) + } + + @Test + fun `시간 동기화는 PC의 8바이트 날짜 형식을 사용한다`() { + val monday = LocalDateTime.of(2026, 8, 31, 14, 5, 9) + assertThat(DctProtocolRequests.syncTime(monday).payload).isEqualTo( + hex("0E 05 09 07 EA 08 1F 40") + ) + } + + @Test + fun `MDB 상수 주소는 범위에 따라 3바이트 또는 4바이트로 직렬화한다`() { + assertThat(DctProtocolRequests.constantWrite(0x123456, hex("12 34")).payload) + .isEqualTo(hex("03 12 34 56 02 12 34")) + assertThat(DctProtocolRequests.constantRead(0x12345678, 4).payload) + .isEqualTo(hex("04 12 34 56 78 04")) + } + + @Test + fun `XCP Characteristic은 타입 값 주소 순서를 따른다`() { + val request = DctProtocolRequests.xcpCharacteristicWrite( + address = 0x12345678, + dataType = A2lDataType.UWORD, + value = 0x1020.toDouble(), + isMsbFirst = true + ) + assertThat(request.payload).isEqualTo(hex("02 10 20 12 34 56 78")) + + val read = DctProtocolRequests.xcpCharacteristicRead( + address = 0x12345678, + dataType = A2lDataType.UWORD, + isMsbFirst = false + ) + assertThat(read.payload).isEqualTo(hex("02 78 56 34 12")) + } + + @Test + fun `XCP 측정 목록은 FLOAT 원본 타입과 주소 엔디안을 보존한다`() { + val signal = SignalDefinition( + reclid = 0, + idx = 0, + name = "FloatValue", + attr = MdbAttrType.ATTR_4_UINT32, + cvsA = 1.0, + cvsB = 0.0, + unit = "", + color = 0, + min = 0.0, + max = 1.0, + adl = 0x12345678, + a2lDataType = A2lDataType.FLOAT32_IEEE + ) + + assertThat(DctProtocolRequests.xcpMeasurements(listOf(signal), isMsbFirst = true).payload) + .isEqualTo(hex("00 01 09 12 34 56 78")) + assertThat(DctProtocolRequests.xcpMeasurements(listOf(signal), isMsbFirst = false).payload) + .isEqualTo(hex("00 01 09 78 56 34 12")) + } + + @Test + fun `DID 명령과 응답 값은 PC enum과 일치한다`() { + assertThat(com.dct.app.domain.model.protocol.DctCommand.CMD_DID_INFO.code).isEqualTo(0x15.toByte()) + assertThat(com.dct.app.domain.model.protocol.DctCommand.CMD_REQ_ALL_DID_INFO.code).isEqualTo(0x18.toByte()) + assertThat(com.dct.app.domain.model.protocol.DctCommand.RES_DID_INFO.code).isEqualTo(0x95.toByte()) + assertThat(com.dct.app.domain.model.protocol.DctCommand.RES_REQ_ALL_DID_INFO.code).isEqualTo(0x98.toByte()) + } + + @Test + fun `P0 장비 설정 페이로드는 PC MainFrm 직렬화와 일치한다`() { + val signals = listOf( + SignalDefinition(1, 1, "Speed", MdbAttrType.ATTR_2_UINT16, 1.0, 0.0, "", 0, 0.0, 1.0), + SignalDefinition(1, 2, "Temp", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 1.0) + ) + + assertThat(DctProtocolRequests.carType("NQ5", 2).payload) + .isEqualTo(hex("03 4E 51 35 02")) + assertThat(DctProtocolRequests.mdbInfo(signals).payload) + .isEqualTo(hex("01 00 01 00 02 00 01 02 00 02 01")) + assertThat(DctProtocolRequests.mdbSelectedIds(signals).payload) + .isEqualTo(hex("01 01 02 01 02")) + assertThat(DctProtocolRequests.canDbcInfo(listOf(DctCanMessageConfiguration(0x7E0, 8))).payload) + .isEqualTo(hex("01 00 01 07 E0 08")) + assertThat(DctProtocolRequests.sensorModuleInfo(createDefaultSensorModules()).payload) + .isEqualTo(hex("01 01 FF FF FF FF")) + assertThat(DctProtocolRequests.canMode(isHighSpeedCan = true, isOldModel = true).payload) + .isEqualTo(hex("06 11")) + } + + @Test + fun `MDB wire 폭 축소 후 중복되는 ID와 Index는 설정 전에 거절한다`() { + val base = SignalDefinition(1, 1, "A", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 1.0) + assertThat(runCatching { + DctProtocolRequests.mdbSelectedIds(listOf(base, base.copy(reclid = 257, name = "B"))) + }.isFailure).isTrue() + assertThat(runCatching { + DctProtocolRequests.mdbSelectedIds(listOf(base, base.copy(idx = 257, name = "B"))) + }.isFailure).isTrue() + } + + @Test + fun `PC처럼 넓은 원본 ID를 전송 필드 하위 바이트로 직렬화한다`() { + val wideSignal = SignalDefinition( + 0x1234, 0x5678, "Wide", MdbAttrType.ATTR_0_UINT8, + 1.0, 0.0, "", 0, 0.0, 1.0 + ) + + assertThat(DctProtocolRequests.mdbInfo(listOf(wideSignal)).payload) + .isEqualTo(hex("01 12 34 00 01 56 78 01")) + assertThat(DctProtocolRequests.mdbSelectedIds(listOf(wideSignal)).payload) + .isEqualTo(hex("01 34 01 78")) + assertThat(DctProtocolRequests.canDbcInfo(listOf(DctCanMessageConfiguration(0x800007E0, 8))).payload) + .isEqualTo(hex("01 00 01 07 E0 08")) + } + + private fun hex(value: String): ByteArray = value.trim().split(Regex("\\s+")) + .map { it.toInt(16).toByte() } + .toByteArray() +} diff --git a/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt new file mode 100644 index 0000000..f411cb6 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt @@ -0,0 +1,146 @@ +package com.dct.app.tests + +import com.dct.app.data.protocol.CanSignalBitDecoder +import com.dct.app.data.protocol.CanFrameSignalDecoder +import com.dct.app.data.protocol.DctStreamPayloadParser +import com.dct.app.data.protocol.MdbRecordDecoder +import com.dct.app.domain.model.mdb.CanMultiplexRole +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("DCT A3/A4 실시간 payload 파서") +class DctStreamPayloadParserTest { + @Test + fun `MDB 스트림은 type과 ReclID를 데이터에서 분리한다`() { + val parsed = DctStreamPayloadParser.parseCanStream(hex("01 07 34 12")) + as DctStreamPayloadParser.CanStreamPayload.Mdb + assertThat(parsed.recordId).isEqualTo(7) + assertThat(parsed.data).isEqualTo(hex("34 12")) + } + + @Test + fun `CAN 스트림은 페이지와 복수 프레임 및 NA flag를 파싱한다`() { + val parsed = DctStreamPayloadParser.parseCanStream( + hex("02 01 02 02 23 01 02 AA BB 56 04 81 CC") + ) as DctStreamPayloadParser.CanStreamPayload.Can + assertThat(parsed.currentPage).isEqualTo(1) + assertThat(parsed.totalPages).isEqualTo(2) + assertThat(parsed.frames).hasSize(2) + assertThat(parsed.frames[0].canId).isEqualTo(0x0123) + assertThat(parsed.frames[0].data).isEqualTo(hex("AA BB")) + assertThat(parsed.frames[0].isReceived).isTrue() + assertThat(parsed.frames[1].canId).isEqualTo(0x0456) + assertThat(parsed.frames[1].isReceived).isFalse() + } + + @Test + fun `SM 스트림은 모듈 채널 부호 소수와 NC를 파싱한다`() { + val parsed = DctStreamPayloadParser.parseSensorStream( + hex("01 02 02 03 25 10 22 00 04 FF FF 00 00") + )!! + assertThat(parsed).hasSize(1) + assertThat(parsed[0].moduleIndex).isEqualTo(2) + assertThat(parsed[0].channels[0].channelIndex).isEqualTo(3) + assertThat(parsed[0].channels[0].value).isWithin(0.0001).of(-37.34) + assertThat(parsed[0].channels[1].isNotConnected).isTrue() + assertThat(parsed[0].channels[1].value).isNull() + } + + @Test + fun `잘린 프레임과 잘못된 DLC는 거절한다`() { + assertThat(DctStreamPayloadParser.parseCanStream(hex("02 01 01 01 23"))).isNull() + assertThat(DctStreamPayloadParser.parseCanStream(hex("02 01 01 01 23 01 41"))).isNull() + assertThat(DctStreamPayloadParser.parseSensorStream(hex("01 00 01 00 01"))).isNull() + } + + @Test + fun `Intel 및 Motorola DBC bit field를 추출한다`() { + assertThat(CanSignalBitDecoder.decode(hex("F0 0A"), 4, 8, false, false)).isEqualTo(0xAF) + assertThat(CanSignalBitDecoder.decode(hex("12 34"), 7, 8, true, false)).isEqualTo(0x12) + assertThat(CanSignalBitDecoder.decode(hex("F0"), 4, 4, false, true)).isEqualTo(-1) + } + + @Test + fun `멀티플렉서 값과 일치하는 DBC 신호만 디코딩한다`() { + fun signal(name: String, startBit: Int, role: CanMultiplexRole, mux: Long? = null) = + SignalDefinition( + reclid = 0, + idx = startBit, + name = name, + attr = MdbAttrType.ATTR_0_UINT8, + cvsA = 1.0, + cvsB = 0.0, + unit = "", + color = 0, + min = 0.0, + max = 255.0, + startBit = startBit, + bitLength = if (role == CanMultiplexRole.MULTIPLEXOR) 4 else 8, + multiplexRole = role, + multiplexValue = mux + ) + + val decoded = CanFrameSignalDecoder.decode( + data = hex("07 2A 63"), + signals = listOf( + signal("Mode", 0, CanMultiplexRole.MULTIPLEXOR), + signal("Value0", 8, CanMultiplexRole.MULTIPLEXED, 0), + signal("Value7", 8, CanMultiplexRole.MULTIPLEXED, 7), + signal("Always", 16, CanMultiplexRole.NONE) + ) + ) + + assertThat(decoded.map { it.definition.name }).containsExactly("Mode", "Value7", "Always").inOrder() + assertThat(decoded.first { it.definition.name == "Value7" }.physicalValue).isEqualTo(42.0) + } + + @Test + fun `스트림의 중복 인덱스 잔여 바이트와 잘린 XCP 항목을 거절한다`() { + assertThat(DctStreamPayloadParser.parseCanStream(hex("02 01 01 00 FF"))).isNull() + assertThat(DctStreamPayloadParser.parseSensorStream(hex("01 00 00 FF"))).isNull() + assertThat(DctStreamPayloadParser.parseSensorStream(hex("02 00 00 00 00"))).isNull() + assertThat(DctStreamPayloadParser.parseXcpStream(hex("00 01 02 34"))).isNull() + + val xcp = DctStreamPayloadParser.parseXcpStream(hex("00 02 02 A0 0F 00 96"))!! + assertThat(xcp.values).hasSize(2) + assertThat(xcp.values[0].data).isEqualTo(hex("A0 0F")) + } + + @Test + fun `MDB 레코드는 전체 스키마가 있을 때만 원자적으로 디코딩한다`() { + val first = SignalDefinition( + 7, 1, "First", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 255.0, + arrayIndex = 0 + ) + val second = SignalDefinition( + 7, 2, "Second", MdbAttrType.ATTR_2_UINT16, 1.0, 0.0, "", 0, 0.0, 65535.0, + arrayIndex = 1 + ) + val success = MdbRecordDecoder.decode(7, hex("0A 34 12"), listOf(first, second)) + as MdbRecordDecoder.DecodeResult.Success + assertThat(success.signals.map { it.physicalValue }).containsExactly(10.0, 4660.0).inOrder() + + assertThat(MdbRecordDecoder.decode(7, hex("0A 34"), listOf(first, second))) + .isInstanceOf(MdbRecordDecoder.DecodeResult.Failure::class.java) + assertThat(MdbRecordDecoder.decode(7, hex("0A 34 12"), listOf(first, second.copy(idx = 1)))) + .isInstanceOf(MdbRecordDecoder.DecodeResult.Failure::class.java) + } + + @Test + fun `64비트 unsigned CAN raw는 음수로 변환하지 않는다`() { + val signal = SignalDefinition( + 0, 0, "U64", MdbAttrType.ATTR_4_UINT32, 1.0, 0.0, "", 0, 0.0, Double.MAX_VALUE, + startBit = 0, + bitLength = 64 + ) + val decoded = CanFrameSignalDecoder.decode(ByteArray(8) { 0xFF.toByte() }, listOf(signal)).single() + assertThat(decoded.physicalValue).isGreaterThan(Long.MAX_VALUE.toDouble()) + } + + private fun hex(value: String): ByteArray = value.trim().split(Regex("\\s+")) + .map { it.toInt(16).toByte() } + .toByteArray() +} diff --git a/Android/app/src/test/java/com/dct/app/tests/DctVersionResponseTest.kt b/Android/app/src/test/java/com/dct/app/tests/DctVersionResponseTest.kt new file mode 100644 index 0000000..de142ad --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/DctVersionResponseTest.kt @@ -0,0 +1,61 @@ +package com.dct.app.tests + +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.data.protocol.DctResponseValidator +import com.dct.app.domain.model.protocol.DctPacket +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class DctVersionResponseTest { + private fun validate(payload: ByteArray) { + // Include wire encoding/decoding; the frame length and version length are different fields. + val codec = DctPacketCodec() + val response = codec.decode(codec.encode(DctPacket(command = 0x81.toByte(), payload = payload)))!! + DctResponseValidator.validateSetup(DctProtocolRequests.version(), response) + } + + @Test fun acceptsPcLengthPrefixedVersion() { + val version = "DCT-MOBI-v1.0.0".toByteArray(Charsets.US_ASCII) + assertEquals(15, version.size) + validate(byteArrayOf(0x0F) + version) + } + + @Test fun versionLengthIsUnsigned() { + validate(byteArrayOf(0xFF.toByte()) + ByteArray(255) { 65 }) + } + + @Test fun acceptsOnlyIdentifiableNullTerminatedLegacyTestVersions() { + validate("DCT-MOBI-v1.0.0\u0000".toByteArray(Charsets.US_ASCII)) + validate("DCT-MOBI-v12.34.56\u0000".toByteArray(Charsets.US_ASCII)) + } + + @Test fun rejectsMalformedLengthPrefixedResponses() { + listOf( + byteArrayOf(), byteArrayOf(0), byteArrayOf(1), byteArrayOf(8, 65), + byteArrayOf(1, 65, 66), byteArrayOf(0xFF.toByte()) + ByteArray(254) { 65 }, + byteArrayOf(15) + "DCT-MOBI-v1.0.0\u0000".toByteArray(Charsets.US_ASCII) + ).forEach { payload -> + assertThrows(IllegalArgumentException::class.java) { validate(payload) } + } + } + + @Test fun legacyCompatibilityDoesNotAcceptArbitraryTextOrCorruptedData() { + listOf( + "DCT-MOBI-v1.0.0", "DCT-MOBI-v1.0.0\u0000\u0000", + "DCT-MOBI-v1.0.0\u0000junk", "DCT-MOBI-v1.0\u0000", + "DCT-MOBI-v1.A.0\u0000", "DCT-MOBI-v1.\u0000.0\u0000", "other firmware\u0000", + "DCT-MOBI-v" + "1".repeat(250) + ".0.0\u0000" + ).forEach { text -> + assertThrows(IllegalArgumentException::class.java) { validate(text.toByteArray(Charsets.US_ASCII)) } + } + } + + @Test fun validVersionCannotApproveWrongResponseCommand() { + assertThrows(IllegalArgumentException::class.java) { + DctResponseValidator.validateSetup( + DctProtocolRequests.version(), DctPacket(command = 0x83.toByte(), payload = byteArrayOf(1, 65)) + ) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt new file mode 100644 index 0000000..972749a --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt @@ -0,0 +1,539 @@ +package com.dct.app.tests + +import com.dct.app.data.repository.DpcProjectManager +import com.dct.app.domain.model.mdb.CompensationMap +import com.dct.app.domain.model.mdb.CompensationMapType +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.MdbConstant +import com.dct.app.domain.model.mdb.MdbDatabase +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.mdb.SignalGroup +import com.dct.app.domain.model.project.DpcProject +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class DpcProjectManagerTest { + @Test + @DisplayName("P3 ask_info가 저장과 복원에서 보존되어야 한다") + fun testAskInfoRoundtrip() { + val project = DpcProject( + askInfo = com.dct.app.domain.model.project.DpcAskInfo( + firmwareAddress = "1234ABCD", + stMin = 5, + seedKeyPath = "vendor.so", + dependsOnRom = true + ) + ) + + val parsed = DpcProjectManager.parseProjectFromJson( + DpcProjectManager.serializeProjectToJson(project), + null + ) + + assertEquals(project.askInfo, parsed.askInfo) + } + + @Test + @DisplayName("DPC 프로젝트 직렬화 시 PC C++ 호환 27개 루트 키 및 프로퍼티 구조가 완벽히 생성되어야 한다") + fun testSerializeProjectToJson_FullPcCompatibility() { + val signals = listOf( + SignalDefinition( + reclid = 1, + idx = 1, + name = "Engine_Speed", + attr = MdbAttrType.ATTR_2_UINT16, + cvsA = 1.0, + cvsB = 0.0, + unit = "rpm", + color = 0x00FF0000L, + min = 0.0, + max = 8000.0, + description = "실시간 RPM", + adl = 100 + ) + ) + val constants = listOf( + MdbConstant( + idx = 10, + name = "Target_Idle_RPM", + data = 750.0, + addr = 200L, + min = 500.0, + max = 1200.0, + attr = MdbAttrType.ATTR_2_UINT16, + description = "목표 아이들 RPM" + ) + ) + val compA = listOf( + CompensationMap( + type = CompensationMapType.TYPE_A, + idx = 1, + yName = "Idle_Base_Duty", + yAddr = 310L, + yAttr = MdbAttrType.ATTR_2_UINT16, + yMin = 0.0, + yMax = 100.0, + xName = "Coolant_Temp", + xAddr = 300L, + xAttr = MdbAttrType.ATTR_2_UINT16, + cvsA = 1.0, + cvsB = 0.0, + description = "냉각수온 보정" + ) + ) + + val mdbDb = MdbDatabase( + groups = listOf(SignalGroup(reclid = 1, strIndex = "1", description = "Engine", usage = true, signals = signals)), + constants = constants, + compensationMaps = compA + ) + + val canSignals = listOf( + SignalDefinition( + reclid = 2, + idx = 1, + name = "CAN_Vehicle_Speed", + attr = MdbAttrType.ATTR_2_UINT16, + cvsA = 1.0, + cvsB = 0.0, + unit = "km/h", + color = 0x0000FF00L, + min = 0.0, + max = 250.0, + description = "차량 차속", + adl = 50 + ) + ) + + val a2lSignals = listOf( + SignalDefinition( + reclid = 3, + idx = 1, + name = "A2L_Battery_Voltage", + attr = MdbAttrType.ATTR_2_UINT16, + cvsA = 0.1, + cvsB = 0.0, + unit = "V", + color = 0x000000FFL, + min = 0.0, + max = 18.0, + description = "배터리 전압", + adl = 60 + ) + ) + + val project = DpcProject( + projectFileName = "TEST_PROJECT", + vehicleCode = "GENESIS_G90", + mdbFileName = "Engine_Data.mdb", + canDbcFileName = "Vehicle_CAN.dbc", + a2lFileName = "ECU_Firmware.a2l", + writeCsv = true, + writeMf4 = false, + canMode = "CAN(HiSpeed)", + forOldModel = false, + mdbDatabase = mdbDb, + canSignals = canSignals, + a2lSignals = a2lSignals + ) + + val jsonStr = DpcProjectManager.serializeProjectToJson(project) + val json = JSONObject(jsonStr) + + // 1. Root Keys 검증 + assertEquals("TEST_PROJECT", json.getString("name")) + assertEquals("GENESIS_G90", json.getString("cartype")) + assertEquals(1, json.getInt("canmode")) + assertEquals(0, json.getInt("oldcanmode")) + assertEquals(1, json.getInt("writecsv")) + assertEquals(0, json.getInt("writemf4")) + assertTrue(json.has("base_folder")) + assertTrue(json.has("DID_INFO")) + assertTrue(json.has("hw_device")) + assertTrue(json.has("ask_info")) + assertTrue(json.has("ota_info")) + assertTrue(json.has("view_rect")) + assertTrue(json.has("graphItem_property")) + + // 2. external_file 검증 (4개 엔트리) + val extFiles = json.getJSONArray("external_file") + assertEquals(4, extFiles.length()) + val mdbExt = extFiles.getJSONObject(0) + assertEquals("mdb", mdbExt.getString("ext")) + assertEquals("\\MDB\\Engine_Data.mdb", mdbExt.getString("path")) + assertEquals(1, mdbExt.getInt("mdbfile_path_use_basefolder")) + + val dbcExt = extFiles.getJSONObject(1) + assertEquals("dbc", dbcExt.getString("ext")) + assertEquals("\\DBC\\Vehicle_CAN.dbc", dbcExt.getString("path")) + assertEquals(1, dbcExt.getInt("dbcfile_path_use_basefolder")) + + // 3. candbc_property 검증 + assertTrue(json.has("candbc_property")) + val canProp = json.getJSONObject("candbc_property").getJSONArray("property").getJSONObject(0) + val canItems = canProp.getJSONArray("items") + assertEquals("CAN_Vehicle_Speed", canItems.getJSONObject(0).getString("Name")) + assertEquals(0x0000FF00L, canItems.getJSONObject(0).getLong("USER_COLOR")) + assertEquals("차량 차속", canItems.getJSONObject(0).getString("Descrip")) + + // 4. mdb_recv_property 검증 + assertTrue(json.has("mdb_recv_property")) + val mdbRecv = json.getJSONObject("mdb_recv_property").getJSONArray("property") + val sigGroup = mdbRecv.getJSONObject(0) + assertEquals("MDB Signal", sigGroup.getString("name")) + val sigItems = sigGroup.getJSONArray("items") + assertEquals("Engine_Speed", sigItems.getJSONObject(0).getString("Name")) + assertEquals(0x00FF0000L, sigItems.getJSONObject(0).getLong("USER_COLOR")) + assertEquals("실시간 RPM", sigItems.getJSONObject(0).getString("Descrip")) + + val conGroup = mdbRecv.getJSONObject(1) + assertEquals("Constant Signal", conGroup.getString("name")) + val conItems = conGroup.getJSONArray("items") + assertEquals("Target_Idle_RPM", conItems.getJSONObject(0).getString("Name")) + assertEquals(750.0, conItems.getJSONObject(0).getDouble("Value")) + + // 5. mdb_compensation_A_property 검증 + assertTrue(json.has("mdb_compensation_A_property")) + val compAProp = json.getJSONObject("mdb_compensation_A_property").getJSONArray("items") + assertEquals("Coolant_Temp", compAProp.getJSONObject(0).getString("XNAME")) + assertEquals("Idle_Base_Duty", compAProp.getJSONObject(0).getString("YNAME")) + + // 6. a2l_property 검증 + assertTrue(json.has("a2l_property")) + val a2lItems = json.getJSONObject("a2l_property").getJSONObject("measurement").getJSONArray("items") + assertEquals("A2L_Battery_Voltage", a2lItems.getJSONObject(0).getString("Name")) + assertEquals(0x000000FFL, a2lItems.getJSONObject(0).getLong("USER_COLOR")) + assertEquals("배터리 전압", a2lItems.getJSONObject(0).getString("Descrip")) + } + + @Test + @DisplayName("PC 원본 .dpc JSON을 로드하여 파싱하고 다시 저장할 때 원본 속성이 100% 보존되어야 한다") + fun testParseAndRoundtripPcJson() { + val samplePcJson = """ + { + "DID_INFO" : { + "DIDF100PN2" : "", + "DIDF187PN" : "", + "DIDF18BPD" : "", + "DIDF191HW" : "", + "DIDF193SHW" : "", + "DIDF197SN" : "", + "DIDF1A1SC" : "", + "DIDPreSetting" : 49 + }, + "ask_info" : { + "ASKFWAddr" : "", + "ASKSTMin" : 0, + "seedKeyPath" : "" + }, + "autostart_measuring_recording" : 0, + "base_folder" : "C:\\Users\\COMPUTER1\\Documents\\DCT Work", + "candbc_property" : { + "property" : [ + { + "id" : 0, + "items" : [ + { + "Descrip" : "엔진 속도", + "Name" : "Engine_RPM", + "USER_COLOR" : 65280, + "item_dashstyle" : 0, + "item_thickness" : 2 + } + ], + "name" : "CAN Signal" + } + ] + }, + "canmode" : 1, + "cartype" : "NQ5", + "copy_savedfolder" : "C:\\Users\\COMPUTER1\\Documents\\DCT Work\\Data", + "external_file" : [ + { + "ext" : "mdb", + "mdbfile_path_use_basefolder" : 1, + "path" : "\\MDB\\NQ5_Seat.mdb" + }, + { + "dbcfile_path_use_basefolder" : 0, + "ext" : "dbc", + "path" : "C:\\CanDb.dbc" + }, + { + "ext" : "smf", + "path" : "", + "smffile_path_use_basefolder" : 0 + }, + { + "a2lfile_path_use_basefolder" : 0, + "ext" : "a2l", + "path" : "" + } + ], + "graphItem_property" : { + "graph_linear" : true + }, + "hw_device" : { + "com_port" : "", + "connectedType" : 1, + "host_address" : "", + "macaddr" : "", + "sm_count" : 0 + }, + "isave_logfile" : 1, + "layout_wnd" : { + "items" : [], + "layoutcnt" : 0 + }, + "logfile_path" : "\\Data", + "logfile_path_use_basefolder" : 1, + "maker" : 2, + "name" : "PROJECT", + "oldcanmode" : 0, + "version" : "0.0.0.2", + "writecsv" : 1, + "writemf4" : 0 + } + """.trimIndent() + + val parsed = DpcProjectManager.parseProjectFromJson(samplePcJson, null) + assertEquals("PROJECT", parsed.projectFileName) + assertEquals("NQ5", parsed.vehicleCode) + assertEquals("NQ5_Seat.mdb", parsed.mdbFileName) + assertEquals("CanDb.dbc", parsed.canDbcFileName) + assertEquals("CAN(HiSpeed)", parsed.canMode) + assertEquals(true, parsed.writeCsv) + assertEquals(65280L, parsed.canSignalColors["Engine_RPM"]) + assertEquals("엔진 속도", parsed.canSignalDescriptions["Engine_RPM"]) + assertNotNull(parsed.rawJsonRoot) + + // 수정 후 재직렬화 + val modified = parsed.copy(vehicleCode = "NQ5_FACELIFT") + val reSerialized = DpcProjectManager.serializeProjectToJson(modified) + val reJson = JSONObject(reSerialized) + + assertEquals("NQ5_FACELIFT", reJson.getString("cartype")) + assertEquals("PROJECT", reJson.getString("name")) + assertEquals(1, reJson.getInt("canmode")) + assertEquals(1, reJson.getInt("writecsv")) + assertTrue(reJson.has("DID_INFO")) + assertTrue(reJson.has("candbc_property")) + assertTrue(reJson.has("layout_wnd")) + } + + @Test + @DisplayName("하드코딩 없는 동적 base_folder 및 use_basefolder 상대 경로 직렬화/역직렬화가 정상 동작해야 한다") + fun testDynamicBaseFolderAndUseBaseFolderResolution() { + val proj = DpcProject( + projectFileName = "CustomTest", + baseFolder = "D:\\MyWorks\\CustomDCT", + mdbFileName = "MyEcu.mdb", + canDbcFileName = "MyCan.dbc", + a2lFileName = "MyA2l.a2l", + measuredFilesFolder = "D:\\MyWorks\\CustomDCT\\Data" + ) + + val jsonStr = DpcProjectManager.serializeProjectToJson(proj) + val json = JSONObject(jsonStr) + + assertEquals("D:\\MyWorks\\CustomDCT", json.getString("base_folder")) + assertEquals("\\Data", json.getString("logfile_path")) + assertEquals(1, json.getInt("logfile_path_use_basefolder")) + + val extFiles = json.getJSONArray("external_file") + var mdbObj: JSONObject? = null + var dbcObj: JSONObject? = null + var a2lObj: JSONObject? = null + for (i in 0 until extFiles.length()) { + val item = extFiles.getJSONObject(i) + when (item.getString("ext")) { + "mdb" -> mdbObj = item + "dbc" -> dbcObj = item + "a2l" -> a2lObj = item + } + } + + assertNotNull(mdbObj) + assertEquals("\\MDB\\MyEcu.mdb", mdbObj!!.getString("path")) + assertEquals(1, mdbObj.getInt("mdbfile_path_use_basefolder")) + + assertNotNull(dbcObj) + assertEquals("\\DBC\\MyCan.dbc", dbcObj!!.getString("path")) + assertEquals(1, dbcObj.getInt("dbcfile_path_use_basefolder")) + + assertNotNull(a2lObj) + assertEquals("\\A2L\\MyA2l.a2l", a2lObj!!.getString("path")) + assertEquals(1, a2lObj.getInt("a2lfile_path_use_basefolder")) + + // 역직렬화 검증 + val parsed = DpcProjectManager.parseProjectFromJson(jsonStr, null) + assertEquals("D:\\MyWorks\\CustomDCT", parsed.baseFolder) + assertEquals("MyEcu.mdb", parsed.mdbFileName) + assertEquals("MyCan.dbc", parsed.canDbcFileName) + assertEquals("MyA2l.a2l", parsed.a2lFileName) + } + + @Test + @DisplayName("createDefaultProject 호출 시 안드로이드 Documents/DCT Work/Data 경로가 기본값으로 고정되고 projectFileName은 빈칸이어야 한다") + fun testDefaultMeasuredFolderAndCreateDefaultProject() { + val proj = DpcProjectManager.createDefaultProject() + assertEquals("", proj.projectFileName) + assertNotNull(proj.baseFolder) + assertTrue(proj.baseFolder.contains("DCT Work")) + assertNotNull(proj.measuredFilesFolder) + assertTrue(proj.measuredFilesFolder.contains("DCT Work") && proj.measuredFilesFolder.contains("Data")) + + val jsonStr = DpcProjectManager.serializeProjectToJson(proj) + val json = JSONObject(jsonStr) + assertTrue(json.has("base_folder")) + assertTrue(json.has("logfile_path")) + } + + @Test + @DisplayName("DID 정보 및 Sensor Module 1~4 설정이 .dpc JSON으로 정확히 직렬화되고 역직렬화되어야 한다") + fun testDidInfoAndSensorModulesSerializationAndDeserialization() { + val did = com.dct.app.domain.model.project.DpcDidInfo( + partName2 = "GENESIS_ECU_V2", + ecuHwVersion = "H010", + partNumber = "391102B000", + productDate = "20260825", + supplierCode = "MOBI", + systemName = "DCTS", + supplierHwVersion = "S001" + ) + val smList = com.dct.app.domain.model.project.createDefaultSensorModules().toMutableList() + // SM 1 채널 1을 Voltage로 변경하고 Description 설정 + val sm0Ch = smList[0].channels.toMutableList() + sm0Ch[0] = sm0Ch[0].copy(isVoltage = true, unit = "V", description = "배터리 전압 센서") + smList[0] = smList[0].copy(isUsed = true, channels = sm0Ch) + + val project = com.dct.app.domain.model.project.DpcProject( + projectFileName = "TEST_ADVANCED", + writeCsv = true, + writeMf4 = false, + canMode = "CAN(FD)", + forOldModel = true, + didInfo = did, + sensorModules = smList + ) + + val jsonStr = DpcProjectManager.serializeProjectToJson(project) + val json = JSONObject(jsonStr) + + // 1. DID_INFO 키 검증 + assertTrue(json.has("DID_INFO")) + val didObj = json.getJSONObject("DID_INFO") + assertEquals("GENESIS_ECU_V2", didObj.getString("DIDF100PN2")) + assertEquals("H010", didObj.getString("DIDF191HW")) + assertEquals("391102B000", didObj.getString("DIDF187PN")) + assertEquals("20260825", didObj.getString("DIDF18BPD")) + assertEquals("MOBI", didObj.getString("DIDF1A1SC")) + assertEquals("DCTS", didObj.getString("DIDF197SN")) + assertEquals("S001", didObj.getString("DIDF193SHW")) + + // 2. sm_property 키 검증 + assertTrue(json.has("sm_property")) + val smArr = json.getJSONObject("sm_property").getJSONArray("modules") + assertEquals(4, smArr.length()) + val sm0 = smArr.getJSONObject(0) + assertEquals(1, sm0.getInt("is_used")) + val ch0 = sm0.getJSONArray("channels").getJSONObject(0) + assertEquals(1, ch0.getInt("is_voltage")) + assertEquals("V", ch0.getString("unit")) + assertEquals("배터리 전압 센서", ch0.getString("description")) + + // 3. CAN mode 및 Logging 플래그 검증 + assertEquals(0, json.getInt("canmode")) // CAN(FD) -> 0 + assertEquals(1, json.getInt("oldcanmode")) // For old model -> 1 + assertEquals(1, json.getInt("writecsv")) + assertEquals(0, json.getInt("writemf4")) + + // 4. 역직렬화 검증 + val parsed = DpcProjectManager.parseProjectFromJson(jsonStr, null) + assertEquals("GENESIS_ECU_V2", parsed.didInfo.partName2) + assertEquals("391102B000", parsed.didInfo.partNumber) + assertEquals("CAN(FD)", parsed.canMode) + assertTrue(parsed.forOldModel) + assertTrue(parsed.writeCsv) + assertFalse(parsed.writeMf4) + assertEquals(4, parsed.sensorModules.size) + assertTrue(parsed.sensorModules[0].channels[0].isVoltage) + assertEquals("배터리 전압 센서", parsed.sensorModules[0].channels[0].description) + } + + @Test + @DisplayName("ensureDctWorkDirectories 호출 시 PC 표준 8대 서브폴더가 모두 생성되어야 한다") + fun testEnsureDctWorkDirectoriesWithAllSubfolders() { + val baseDir = DpcProjectManager.ensureDctWorkDirectories() + assertTrue(baseDir.exists()) + + val expectedSubFolders = listOf( + "Configuration", + "Data", + "MDB", + "DBC", + "A2L", + "SMC", + "Log", + "Screen" + ) + for (sub in expectedSubFolders) { + val subDir = java.io.File(baseDir, sub) + assertTrue(subDir.exists(), "서브폴더 $sub 가 자동 생성되어야 합니다.") + } + } + + @Test + @DisplayName("파일 기반 프로젝트 저장 및 loadProjectFromFile 역직렬화 검증") + fun testSaveAndLoadProjectFromFile() { + val tempDir = java.io.File(System.getProperty("java.io.tmpdir"), "dct_test_config") + tempDir.mkdirs() + val tempFile = java.io.File(tempDir, "TEST_CONFIG_PROJ.dpc") + + val project = DpcProject( + projectFileName = "TEST_CONFIG_PROJ", + vehicleCode = "DN8", + mdbFileName = "DN8_Engine.mdb", + canDbcFileName = "DN8_CAN.dbc", + canMode = "CAN(HiSpeed)", + writeCsv = true + ) + val json = DpcProjectManager.serializeProjectToJson(project) + tempFile.writeText(json, Charsets.UTF_8) + + val loaded = DpcProjectManager.loadProjectFromFile(tempFile) + assertNotNull(loaded) + assertEquals("TEST_CONFIG_PROJ.dpc", loaded!!.projectFileName) + assertEquals("DN8", loaded.vehicleCode) + assertEquals("DN8_Engine.mdb", loaded.mdbFileName) + assertEquals("DN8_CAN.dbc", loaded.canDbcFileName) + assertEquals(tempFile.absolutePath, loaded.localProjectPath) + + tempFile.delete() + tempDir.delete() + } + + @Test + @DisplayName("isProjectFileExists 및 getFilesByExtension 동작 검증") + fun testIsProjectFileExistsAndGetFilesByExtension() { + val configDir = java.io.File(DpcProjectManager.getDefaultConfigurationFolder()) + configDir.mkdirs() + val testFile = java.io.File(configDir, "EXISTS_TEST_PROJ.dpc") + testFile.writeText("{}", Charsets.UTF_8) + + assertTrue(DpcProjectManager.isProjectFileExists(null, "EXISTS_TEST_PROJ")) + assertTrue(DpcProjectManager.isProjectFileExists(null, "EXISTS_TEST_PROJ.dpc")) + assertFalse(DpcProjectManager.isProjectFileExists(null, "NON_EXISTING_PROJ_999")) + + val foundFiles = DpcProjectManager.getFilesByExtension(".dpc", "Configuration", null) + assertTrue(foundFiles.any { it.name.equals("EXISTS_TEST_PROJ.dpc", ignoreCase = true) }) + + testFile.delete() + assertFalse(DpcProjectManager.isProjectFileExists(null, "EXISTS_TEST_PROJ")) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt b/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt new file mode 100644 index 0000000..9c65a6a --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt @@ -0,0 +1,106 @@ +package com.dct.app.tests + +import com.dct.app.data.repository.DpcProjectManager +import com.dct.app.data.repository.DpcWorkspaceJson +import com.dct.app.domain.model.chart.* +import com.dct.app.domain.model.project.* +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class DpcWorkspacePersistenceTest { + private fun sample(): DpcWorkspaceState { + val signal = DpcSignalBindingState("sig_1_2", "RPM", AxisPosition.RIGHT_1, 0xFFAABBCCL, + "rpm", 4f, 3, false, InterpolationMode.PWM_STEP) + return DpcWorkspaceState("page_2", listOf( + DpcPageState("page_1", "Engine", windows = listOf( + DpcWindowState("graph", "MULTI_AXIS_GRAPH", "Engine Graph", x = 730f, y = 90f, + width = 650f, height = 420f, zIndex = 3f, isMinimized = true, createdAt = 42, + signals = listOf(signal), chart = DpcChartState( + axes = listOf(ChartAxisConfig(AxisPosition.RIGHT_1, -10.0, 9000.0, false, label = "RPM")), + timeWindowSec = 17.0, timeWindowPreset = TimeWindowPreset.MANUAL, + isXAxisAuto = false, isYAxisAuto = false, legendPosition = 2 + )), + DpcWindowState("alpha", "ALPHANUMERICAL", "Values", targetSignals = listOf("sig_1_2", "can_4"), + isTileMode = false, hiddenSignalIds = setOf("RPM"), highlightColors = mapOf("RPM" to 0xFFFF0000L)) + ), activeWindowId = "graph", scrollX = 250f, scrollY = 60f, signals = listOf(signal), + alphanumericalSignals = listOf("can_4"), mdbSearchQuery = "speed", canSearchQuery = "engine"), + DpcPageState("page_2", "Intentionally empty") + )) + } + + @Test + fun workspaceRoundTripPreservesAllFieldsAndPcKeys() { + val workspace = sample() + val root = JSONObject("""{"layout_wnd":{"items":[{"opaque":"AABBCC"}]},"vendor_custom":{"keep":true}}""") + val encoded = DpcProjectManager.serializeProjectToJson(DpcProject(workspace = workspace, rawJsonRoot = root)) + val restored = DpcProjectManager.parseProjectFromJson(encoded, null) + assertEquals(workspace, restored.workspace) + assertEquals(root.getJSONObject("layout_wnd").toString(), restored.rawJsonRoot!!.getJSONObject("layout_wnd").toString()) + assertTrue(restored.rawJsonRoot!!.getJSONObject("vendor_custom").getBoolean("keep")) + assertFalse(encoded.contains("buffer")) + assertFalse(encoded.contains("isConnected")) + } + + @Test + fun legacyAndFutureExtensionsAreSafe() { + assertNull(DpcProjectManager.parseProjectFromJson("{}", null).workspace) + val future = JSONObject("""{"android_workspace":{"schema_version":99,"future":"untouched"}}""") + val parsed = DpcProjectManager.parseProjectFromJson(future.toString(), null) + assertNull(parsed.workspace) + val saved = JSONObject(DpcProjectManager.serializeProjectToJson(parsed.copy(workspace = sample()))) + assertEquals(future.getJSONObject(DpcWorkspaceJson.KEY).toString(), saved.getJSONObject(DpcWorkspaceJson.KEY).toString()) + } + + @Test + fun malformedFieldsCannotBreakRemainingWorkspace() { + val json = DpcWorkspaceJson.encode(sample()) + json.put("active_page_id", "missing") + val page = json.getJSONArray("pages").getJSONObject(0) + val window = page.getJSONArray("windows").getJSONObject(0) + window.put("width", -1).put("x", "NaN") + val signal = window.getJSONArray("signals").getJSONObject(0) + signal.put("axis", "NEW_AXIS").put("line_width", "Infinity") + val axis = window.getJSONObject("chart").getJSONArray("axes").getJSONObject(0) + axis.put("min", 100).put("max", -100) + val decoded = DpcWorkspaceJson.decode(json)!! + assertEquals("page_1", decoded.activePageId) + val restored = decoded.pages.first().windows.first() + assertEquals(120f, restored.width) + assertEquals(20f, restored.x) + assertEquals(AxisPosition.LEFT_0, restored.signals.first().axisPosition) + assertEquals(2f, restored.signals.first().lineWidthDp) + assertEquals(100.0, restored.chart!!.axes.first().maxVal) + assertEquals(2, decoded.pages.size) + } + + @Test + fun rendererRestoresStylesAndAxesWithoutTelemetryOrSharedBuffers() { + val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) + val saved = sample().pages.first().windows.first() + val series = saved.signals.first().toSeries() + assertEquals(1, series.buffer.capacity) + series.buffer.addSample(1.0, 123.0) + renderer.restoreWorkspaceState(listOf(series), saved.chart) + assertNull(renderer.seriesListFlow.value.first().buffer.getLatestTimestamp()) + assertNotSame(series.buffer, renderer.seriesListFlow.value.first().buffer) + assertEquals(saved.signals, renderer.seriesListFlow.value.map(DpcSignalBindingState::fromSeries)) + assertEquals(saved.chart!!.axes.first(), renderer.exportWorkspaceState().axes.find { it.position == AxisPosition.RIGHT_1 }) + assertEquals(17.0, renderer.exportWorkspaceState().timeWindowSec) + renderer.setSeriesDashStyle(series.signalId, 4) + renderer.setLegendPosition(1) + assertEquals(4, renderer.seriesListFlow.value.first().dashStyle) + assertEquals(1, renderer.exportWorkspaceState().legendPosition) + } + + @Test + fun rendererKeepsInsertionOrderAcrossRestores() { + val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) + val series = listOf("z", "a", "m").map { ChartSignalSeries(it, it, AxisPosition.LEFT_0, 0xFF112233L) } + renderer.restoreWorkspaceState(series, null) + assertEquals(listOf("z", "a", "m"), renderer.buildSnapshot(100).seriesList.map { it.signalId }) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/FirmwareFileParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/FirmwareFileParserTest.kt new file mode 100644 index 0000000..056e44c --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/FirmwareFileParserTest.kt @@ -0,0 +1,51 @@ +package com.dct.app.tests + +import com.dct.app.data.maintenance.FirmwareFileParser +import com.dct.app.domain.model.maintenance.FirmwareFormat +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class FirmwareFileParserTest { + @Test + fun `intel hex validates checksum and fills address gaps with FF`() { + val source = listOf( + ":020000040001F9", + ":020010001122BB", + ":0100130033B9", + ":00000001FF" + ).joinToString("\n").toByteArray() + + val image = FirmwareFileParser.parse("ecu.hex", source) + + assertEquals(FirmwareFormat.INTEL_HEX, image.format) + assertEquals(0x10010L, image.startAddress) + assertEquals(2 * 1024 * 1024, image.bytes.size) + assertArrayEquals(byteArrayOf(0x11, 0x22, 0xFF.toByte(), 0x33), image.bytes.copyOfRange(0, 4)) + assertEquals(0xFF.toByte(), image.bytes.last()) + } + + @Test + fun `s19 validates checksum and fills address gaps with zero`() { + val source = listOf( + "S10510001122B7", + "S104100333B5", + "S9031000EC" + ).joinToString("\n").toByteArray() + + val image = FirmwareFileParser.parse("ecu.s19", source) + + assertEquals(FirmwareFormat.MOTOROLA_S19, image.format) + assertEquals(0x1000L, image.startAddress) + assertArrayEquals(byteArrayOf(0x11, 0x22, 0x00, 0x33), image.bytes) + } + + @Test + fun `corrupt checksum is rejected`() { + val badHex = ":0100000001FF\n:00000001FF".toByteArray() + assertThrows(IllegalArgumentException::class.java) { + FirmwareFileParser.parse("bad.hex", badHex) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaEvaluatorTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaEvaluatorTest.kt new file mode 100644 index 0000000..6bedf76 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/FormulaEvaluatorTest.kt @@ -0,0 +1,96 @@ +package com.dct.app.tests + +import com.dct.app.core.formula.FormulaEvaluator +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import kotlin.math.PI + +class FormulaEvaluatorTest { + + @BeforeEach + fun setUp() { + FormulaEvaluator.clearCache() + } + + @Test + @DisplayName("기본 사칙연산 및 연산자 우선순위 검증") + fun testBasicArithmetic() { + val vars = emptyMap() + assertEquals(7.0, FormulaEvaluator.evaluate("1 + 2 * 3", vars), 0.0001) + assertEquals(9.0, FormulaEvaluator.evaluate("(1 + 2) * 3", vars), 0.0001) + assertEquals(3.5, FormulaEvaluator.evaluate("10 / 2 - 1.5", vars), 0.0001) + assertEquals(1.0, FormulaEvaluator.evaluate("10 % 3", vars), 0.0001) + assertEquals(8.0, FormulaEvaluator.evaluate("2 ^ 3", vars), 0.0001) + } + + @Test + @DisplayName("단항 연산자(Unary Minus/Plus) 검증") + fun testUnaryOperators() { + val vars = emptyMap() + assertEquals(-5.0, FormulaEvaluator.evaluate("-5", vars), 0.0001) + assertEquals(5.0, FormulaEvaluator.evaluate("-5 + 10", vars), 0.0001) + assertEquals(-5.0, FormulaEvaluator.evaluate("-(3 + 2)", vars), 0.0001) + assertEquals(2.0, FormulaEvaluator.evaluate("-3 + 5", vars), 0.0001) + assertEquals(-6.0, FormulaEvaluator.evaluate("2 * -3", vars), 0.0001) + assertEquals(0.125, FormulaEvaluator.evaluate("2 ^ -3", vars), 0.0001) + assertEquals(-4.0, FormulaEvaluator.evaluate("-2 ^ 2", vars), 0.0001) + } + + @Test + @DisplayName("수학 함수 및 상수(sin, cos, sqrt, abs, min, max, pow, pi) 검증") + fun testMathFunctionsAndConstants() { + val vars = emptyMap() + assertEquals(4.0, FormulaEvaluator.evaluate("sqrt(16)", vars), 0.0001) + assertEquals(42.0, FormulaEvaluator.evaluate("abs(-42)", vars), 0.0001) + assertEquals(0.0, FormulaEvaluator.evaluate("sin(0)", vars), 0.0001) + assertEquals(1.0, FormulaEvaluator.evaluate("cos(0)", vars), 0.0001) + assertEquals(10.0, FormulaEvaluator.evaluate("min(10, 20)", vars), 0.0001) + assertEquals(20.0, FormulaEvaluator.evaluate("max(10, 20)", vars), 0.0001) + assertEquals(16.0, FormulaEvaluator.evaluate("pow(2, 4)", vars), 0.0001) + assertEquals(PI, FormulaEvaluator.evaluate("pi", vars), 0.0001) + } + + @Test + @DisplayName("신호 변수 바인딩(큰따옴표 및 식별자) 및 복합 수식 연산 검증") + fun testVariableSubstitution() { + val telemetry = mapOf( + "EngineSpeed" to 3000.0, + "Torque" to 200.0, + "T_Inlet" to 85.5, + "T_Outlet" to 72.3 + ) + + // PC 레거시 큰따옴표 형식 + val powerKw = FormulaEvaluator.evaluate("\"Torque\" * \"EngineSpeed\" / 9549", telemetry) + assertEquals(3000.0 * 200.0 / 9549.0, powerKw, 0.001) + + // 온도차 수식 + val tempDiff = FormulaEvaluator.evaluate("\"T_Inlet\" - \"T_Outlet\"", telemetry) + assertEquals(13.2, tempDiff, 0.001) + + // 따옴표 없는 식별자 형식 + val halfSpeed = FormulaEvaluator.evaluate("EngineSpeed * 0.5", telemetry) + assertEquals(1500.0, halfSpeed, 0.001) + } + + @Test + @DisplayName("0 나누기 및 잘못된 문법에 대한 안전성(NaN / Validation) 검증") + fun testErrorHandling() { + val vars = emptyMap() + assertTrue(FormulaEvaluator.evaluate("10 / 0", vars).isNaN()) + assertTrue(FormulaEvaluator.evaluate("sqrt(-1)", vars).isNaN()) + assertTrue(FormulaEvaluator.evaluate("", vars).isNaN()) + assertTrue(FormulaEvaluator.evaluate("+++", vars).isNaN()) + + assertTrue(FormulaEvaluator.validate("1 + 2 * 3")) + assertTrue(FormulaEvaluator.validate("\"Torque\" * 2")) + assertFalse(FormulaEvaluator.validate("")) + assertFalse(FormulaEvaluator.validate("1 + * 2")) + assertFalse(FormulaEvaluator.validate("1 + 2)")) + assertFalse(FormulaEvaluator.validate("(1 + 2")) + assertFalse(FormulaEvaluator.validate("1 # + 2")) + assertFalse(FormulaEvaluator.validate("\"Torque * 2")) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaIntegrationTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaIntegrationTest.kt new file mode 100644 index 0000000..f600e00 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/FormulaIntegrationTest.kt @@ -0,0 +1,87 @@ +package com.dct.app.tests + +import com.dct.app.core.formula.FormulaEvaluator +import com.dct.app.data.logger.CsvMeasurementLogger +import com.dct.app.data.logger.Mf4MeasurementLogger +import com.dct.app.data.repository.DpcProjectManager +import com.dct.app.domain.model.formula.FormulaDefinition +import com.dct.app.domain.model.project.DpcProject +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class FormulaIntegrationTest { + + @Test + @DisplayName("DpcProject JSON 직렬화 및 역직렬화 시 Formula 목록이 100% 보존되어야 한다") + fun testFormulaProjectSerialization() { + val f1 = FormulaDefinition( + name = "Power_kW", + expression = "\"Torque\" * \"EngineSpeed\" / 9549", + unit = "kW", + color = 0xFF2196F3L, + isForSm = false, + description = "엔진 출력 수식" + ) + val f2 = FormulaDefinition( + name = "Diff_Temp", + expression = "\"T_Inlet\" - \"T_Outlet\"", + unit = "'C", + color = 0xFFFF9800L, + isForSm = true, + description = "입출구 온도차" + ) + + val project = DpcProject( + projectFileName = "FORMULA_TEST.dpc", + formulas = listOf(f1, f2) + ) + + val json = DpcProjectManager.serializeProjectToJson(project) + assertTrue(json.contains("Power_kW")) + assertTrue(json.contains("Diff_Temp")) + + val parsed = DpcProjectManager.parseProjectFromJson(json, null) + assertEquals(2, parsed.formulas.size) + assertEquals("Power_kW", parsed.formulas[0].name) + assertEquals("\"Torque\" * \"EngineSpeed\" / 9549", parsed.formulas[0].expression) + assertEquals("kW", parsed.formulas[0].unit) + assertEquals("Diff_Temp", parsed.formulas[1].name) + assertTrue(parsed.formulas[1].isForSm) + } + + @Test + @DisplayName("Formula 수식 결과가 실시간 CSV 및 MF4 로거 파일에 _formula 접미사로 기록되어야 한다", ) + fun testFormulaLogging(@TempDir tempDir: File) { + val formulaHeaders = listOf("Power_kW" to "kW", "Diff_Temp" to "'C") + val csvName = "TEST_PROJ_2026-08-27_13-30-00_formula.csv" + val mf4Name = "TEST_PROJ_2026-08-27_13-30-00_formula.mf4" + + val telemetry = mapOf("Torque" to 250.0, "EngineSpeed" to 3600.0, "T_Inlet" to 90.0, "T_Outlet" to 75.0) + val pCalc = FormulaEvaluator.evaluate("\"Torque\" * \"EngineSpeed\" / 9549", telemetry) + val tCalc = FormulaEvaluator.evaluate("\"T_Inlet\" - \"T_Outlet\"", telemetry) + + val csvLogger = CsvMeasurementLogger(tempDir, formulaHeaders, csvName) + val csvFile = csvLogger.start() + assertNotNull(csvFile) + assertEquals(csvName, csvFile!!.name) + + csvLogger.logSample(0.1, listOf(pCalc, tCalc)) + csvLogger.stop() + + val csvContent = csvFile.readText() + assertTrue(csvContent.contains("Power_kW")) + assertTrue(csvContent.contains("Diff_Temp")) + + val mf4Logger = Mf4MeasurementLogger(tempDir, formulaHeaders, mf4Name) + val mf4File = mf4Logger.start() + assertNotNull(mf4File) + assertEquals(mf4Name, mf4File!!.name) + + mf4Logger.logSample(0.1, listOf(pCalc, tCalc)) + mf4Logger.stop() + assertTrue(mf4File.length() > 0) + } +} \ No newline at end of file diff --git a/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt new file mode 100644 index 0000000..a6dcc90 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt @@ -0,0 +1,161 @@ +package com.dct.app.tests + +import com.dct.app.data.protocol.* +import com.dct.app.data.maintenance.* +import com.dct.app.domain.model.mdb.* +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.protocol.* +import com.dct.app.domain.model.chart.MultiAxisCoordinateTransformer +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import com.dct.app.presentation.mdi.* +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class GapClosureRegressionTest { + private fun map() = CompensationMap(CompensationMapType.TYPE_A, 1, "Y", 0x345678, MdbAttrType.ATTR_3_INT16, + -300.0, 300.0, "X", 0x123456, MdbAttrType.ATTR_2_UINT16, 0.5, 1.0, + points = listOf(MapPoint(0, 0x1234, -2, 0.0), MapPoint(1, 0x5678, 3, 0.0))) + private fun response(bytes: ByteArray) = DctPacket.create(DctCommand.RES_COMPENSATION_TYPE_DATA, bytes) + + @Test fun calibrationIntegersUseBigEndianAndSignedValues() { + assertArrayEquals(byteArrayOf(0x12, 0x34), DctCalibrationProtocol.encodeInteger(4660.0, MdbAttrType.ATTR_2_UINT16)) + assertArrayEquals(byteArrayOf(-1, -2), DctCalibrationProtocol.encodeInteger(-2.0, MdbAttrType.ATTR_3_INT16)) + assertEquals(-2.0, DctCalibrationProtocol.decodeInteger(byteArrayOf(-1, -2), MdbAttrType.ATTR_3_INT16)) + assertEquals(4294967295.0, DctCalibrationProtocol.decodeInteger(byteArrayOf(-1,-1,-1,-1), MdbAttrType.ATTR_4_UINT32)) + } + @Test fun integerValuesAreNotSilentlyTruncated() { + for (value in listOf(-1.0, 256.0, 1.5, Double.NaN, Double.POSITIVE_INFINITY)) { + assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.encodeInteger(value, MdbAttrType.ATTR_0_UINT8) } + } + } + @Test fun mapGoldenReadAndWrite() { + assertArrayEquals(byteArrayOf(1,0x12,0x34,0x56,2,2), DctCalibrationProtocol.mapRequest(map(), true, false).payload) + assertArrayEquals(byteArrayOf(4,0x34,0x56,0x78,2,2,-1,-2,0,3), DctCalibrationProtocol.mapRequest(map(), false, true).payload) + } + @Test fun mapRejectsTruncatedAddressesAndEmptyPoints() { + for (m in listOf(map().copy(xAddr = 0x1000000), map().copy(xAddr = 0xFFFFFE), map().copy(points = emptyList()))) { + assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.mapRequest(m, true, true) } + } + } + @Test fun mapReadUpdatesRawAndPhysicalForTypeA() { + val read = DctCalibrationProtocol.mapResponse(map(), true, false, response(byteArrayOf(1,1,0x12,0x34,0x56,2,2,0,4,0,8))) + assertEquals(listOf(4,8), read.points.map { it.bkp }) + assertEquals(listOf(3.0,5.0), read.points.map { it.phy }) + assertEquals(map().points.map { it.data }, read.points.map { it.data }) + } + @Test fun mapReadUpdatesPhysicalOnYAxisForTypeB() { + val read = DctCalibrationProtocol.mapResponse(map().copy(type = CompensationMapType.TYPE_B), false, false, + response(byteArrayOf(1,2,0x34,0x56,0x78,2,2,-1,-2,0,4))) + assertEquals(listOf(0.0,3.0), read.points.map { it.phy }) + } + @Test fun mapRejectsNackWrongAddressWrongTypeCountAndLength() { + val good = byteArrayOf(1,1,0x12,0x34,0x56,2,2,0,4,0,8) + for (index in 0..6) { + val invalid = good.copyOf().also { it[index] = 99 } + assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.mapResponse(map(), true, false, response(invalid)) } + } + assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.mapResponse(map(), true, false, response(good.dropLast(1).toByteArray())) } + } + @Test fun mapWriteRequiresEchoedOperationAndAddress() { + val request = DctCalibrationProtocol.mapRequest(map(), true, true) + assertEquals(map(), DctCalibrationProtocol.mapResponse(map(), true, true, response(byteArrayOf(1) + request.payload.take(6).toByteArray()))) + assertThrows(IllegalArgumentException::class.java) { DctCalibrationProtocol.mapResponse(map(), true, true, response(byteArrayOf(1))) } + } + @Test fun setupRejectsNackAndWrongCommand() { + val request = DctProtocolRequests.mdbInfo(emptyList()) + DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_MDB_INFO, byteArrayOf(1))) + assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_MDB_INFO, byteArrayOf(2))) } + assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_ENABLE, byteArrayOf(1))) } + } + @Test fun setupSubtypeIsNotMistakenForSuccess() { + val request = DctProtocolRequests.canMode(true, false) + for (payload in listOf(byteArrayOf(6,1), byteArrayOf(1))) DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, payload)) + for (payload in listOf(byteArrayOf(6,2), byteArrayOf(2), byteArrayOf(1,1))) { + assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(request, DctPacket.create(DctCommand.RES_DCT_ENV, payload)) } + } + } + @Test fun handshakeValidatesVersionAndHardwareIdLengths() { + DctResponseValidator.validateSetup(DctProtocolRequests.version(), DctPacket.create(DctCommand.RES_VERSION, byteArrayOf(1,65))) + assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(DctProtocolRequests.version(), DctPacket.create(DctCommand.RES_VERSION, byteArrayOf(8,65))) } + assertThrows(IllegalArgumentException::class.java) { DctResponseValidator.validateSetup(DctProtocolRequests.hardwareId(), DctPacket.create(DctCommand.RES_HWID, byteArrayOf(1))) } + } + @Test fun xcpHalfPrecisionGoldenFrames() { + assertArrayEquals(byteArrayOf(0x3E,0), XcpValueCodec.encode(A2lDataType.FLOAT16_IEEE, 1.5, true)) + assertArrayEquals(byteArrayOf(0,0xC0.toByte()), XcpValueCodec.encode(A2lDataType.FLOAT16_IEEE, -2.0, false)) + assertArrayEquals(byteArrayOf(0,1), XcpValueCodec.encode(A2lDataType.FLOAT16_IEEE, Math.scalb(1.0,-24), true)) + } + @Test fun xcpRejectsOverflowFractionsAndUnsafe64Bit() { + for ((type, value) in listOf(A2lDataType.UBYTE to 256.0, A2lDataType.SBYTE to 128.0, + A2lDataType.UWORD to 1.1, A2lDataType.UINT64 to 9007199254740992.0, + A2lDataType.FLOAT32_IEEE to Double.MAX_VALUE, A2lDataType.FLOAT16_IEEE to 65505.0)) { + assertThrows(IllegalArgumentException::class.java) { XcpValueCodec.encode(type, value, false) } + } + assertThrows(IllegalArgumentException::class.java) { DctProtocolRequests.xcpCharacteristicWrite(0xFFFFFFFF, A2lDataType.UWORD, 1.0, false) } + } + @Test fun didGoldenFramesAndBatchParsing() { + assertArrayEquals(byteArrayOf(0xF1.toByte(),0x87.toByte(),2,65,66), DeviceMaintenanceProtocol.writeDid(0xF187, byteArrayOf(65,66)).payload) + assertArrayEquals(byteArrayOf(65), DeviceMaintenanceProtocol.parseDid(byteArrayOf(0xF1.toByte(),0x87.toByte(),1,1,65), 0xF187)) + val all = DeviceMaintenanceProtocol.parseAllDid(byteArrayOf(1,0xF1.toByte(),0x87.toByte(),1,65,0xF1.toByte(),0x91.toByte(),1,66)) + assertEquals(setOf(0xF187,0xF191), all.keys) + } + @Test fun didRejectsNackTruncationAndDuplicateIds() { + assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.parseDid(byteArrayOf(-15,-121,2,0),0xF187) } + assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.parseAllDid(byteArrayOf(1,-15,-121,8,65)) } + assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.parseAllDid(byteArrayOf(1,-15,-121,0,-15,-121,0)) } + } + @Test fun firmwareHashUsesActualPcOpcode() { + val packet = DeviceMaintenanceProtocol.hashResponse(DeviceMaintenanceProtocol.HashInfo("fw", ByteArray(32) { it.toByte() }, "A")) + assertEquals(0x32.toByte(), packet.command) + assertArrayEquals(byteArrayOf(3,1,2,102,119,2,32), packet.payload.take(7).toByteArray()) + assertArrayEquals(byteArrayOf(3,2), DeviceMaintenanceProtocol.hashResponse(null).payload) + } + @Test fun firmwareAddressBanksGoldenFrameAndOverflow() { + val packet = DeviceMaintenanceProtocol.firmwareAddresses(listOf(DeviceMaintenanceProtocol.MemoryRegion(0x12345678,0x1000)), emptyList()) + assertArrayEquals(byteArrayOf(1,0x12,0x34,0x56,0x78,0,0,0x10,0,0), packet.payload) + assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.firmwareAddresses(listOf(DeviceMaintenanceProtocol.MemoryRegion(0xFFFFFFFF,2)), emptyList()) } + } + @Test fun maintenanceRequestBoundsAndAsciiValidation() { + assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.databaseName("한글") } + assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.databaseItem(256) } + assertThrows(IllegalArgumentException::class.java) { DeviceMaintenanceProtocol.setSavedInterval(true,3) } + assertArrayEquals(byteArrayOf(0,1), DeviceMaintenanceProtocol.carTypeControl(1,0).payload) + } + @Test fun minimizeMaximizePreserveOriginalBounds() { + val vm = MdiViewModel(TestDispatcherProvider(UnconfinedTestDispatcher())) + val id = vm.addWindow(MdiWindowType.GRAPH, offsetX = 123f, offsetY = 45f, width = 500f)!! + val original = vm.uiState.value.windows.single() + vm.toggleMinimized(id) + assertTrue(vm.uiState.value.windows.single().isMinimized) + vm.toggleMaximized(id) + assertTrue(vm.uiState.value.windows.single().isMaximized) + assertFalse(vm.uiState.value.windows.single().isMinimized) + vm.toggleMaximized(id) + assertEquals(original.width, vm.uiState.value.windows.single().width) + assertEquals(original.offsetX, vm.uiState.value.windows.single().offsetX) + } + @Test fun restoredWindowsCannotBeStrandedWithoutSizeStateButtons() { + val vm = MdiViewModel(TestDispatcherProvider(UnconfinedTestDispatcher())) + vm.addWindow(MdiWindowType.GRAPH, offsetX = 123f, offsetY = 45f, width = 500f, height = 300f) + val normal = vm.uiState.value.windows.single() + for (saved in listOf(normal.copy(isMinimized = true), normal.copy(isMaximized = true))) { + vm.setWindows(listOf(saved), normal.id) + assertEquals(normal, vm.uiState.value.windows.single()) + assertEquals(normal.id, vm.uiState.value.activeWindowId) + } + } + @Test fun rightAxesReserveSeparateSpaceFromLegend() { + val without = MultiAxisCoordinateTransformer.calculatePlotBounds(1000f,600f,1,hasLegend=false) + val with = MultiAxisCoordinateTransformer.calculatePlotBounds(1000f,600f,1,hasLegend=false,rightAxisCount=4) + assertEquals(without.right - 216f, with.right) + } + @Test fun pinchZoomClampsAndPersistsManualSpan() { + val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) + renderer.setTimeWindow(30.0) + renderer.zoomTimeWindow(2f,0.5) + assertEquals(15.0, renderer.exportWorkspaceState().timeWindowSec) + assertFalse(renderer.exportWorkspaceState().isXAxisAuto) + renderer.zoomTimeWindow(Float.NaN,0.5) + assertEquals(15.0, renderer.exportWorkspaceState().timeWindowSec) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt new file mode 100644 index 0000000..6963bcd --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt @@ -0,0 +1,136 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.* +import com.dct.app.domain.model.project.* +import com.dct.app.data.repository.DpcWorkspaceJson +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class GraphSettingsRegressionTest { + private fun renderer() = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())).apply { + bindSignal("a", "A", AxisPosition.LEFT_0, 0xFFL) + bindSignal("b", "B", AxisPosition.LEFT_1, 0xFF0000L) + addSampleSec("a", 1000.0, 1.0) + addSampleSec("a", 1001.0, 2.0) + addSampleSec("b", 1000.0, 500.0) + addSampleSec("b", 1001.0, 600.0) + setTimeWindow(TimeWindowPreset.AUTO) + } + + @Test fun hideKeepsMenuMetadataAndCanBeReversedEvenWhenEverythingIsHidden() { + val r = renderer() + r.setSeriesVisible("a", false) + r.setSeriesVisible("b", false) + val hidden = r.buildSnapshot(600).seriesList + assertEquals(listOf("a", "b"), hidden.map { it.signalId }) + assertTrue(hidden.all { !it.isVisible && it.points.isEmpty() }) + assertNull(hidden[0].minVisibleValue) + r.addSampleSec("a", 1002.0, 3.0) + r.setSeriesVisible("a", true) + assertTrue(r.buildSnapshot(600).seriesList.first().points.isNotEmpty()) + assertEquals(3.0, r.buildSnapshot(600).seriesList.first().latestValue) + } + + @Test fun manualAxisDoesNotDisableOtherAutoAxesAndCanReturnToAuto() { + val r = renderer() + r.setAxisRange(AxisPosition.LEFT_0, -5.0, 5.0, false) + r.addSampleSec("b", 1002.0, 900.0) + val mixed = r.buildSnapshot(600) + assertEquals(5.0, mixed.axesConfig.getValue(AxisPosition.LEFT_0).maxVal) + assertTrue(mixed.axesConfig.getValue(AxisPosition.LEFT_1).maxVal > 900.0) + assertFalse(mixed.isYAxisAuto) + r.setAxisRange(AxisPosition.LEFT_0, -5.0, 5.0, true) + r.addSampleSec("a", 1003.0, 20.0) + assertTrue(r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0).maxVal > 20.0) + assertTrue(r.buildSnapshot(600).isYAxisAuto) + r.setYAxisAuto(false) + r.setAxisAuto(AxisPosition.LEFT_0, true) + assertTrue(r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0).maxVal > 20.0) + assertFalse(r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_1).isAuto) + } + + @Test fun hiddenSeriesDoesNotAffectSharedAutoRange() { + val r = renderer() + r.assignSeriesAxis("b", AxisPosition.LEFT_0) + r.setSeriesVisible("b", false) + assertTrue(r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0).maxVal < 10.0) + } + + @Test fun legendNoneAndHiddenSignalsAndMixedAxesSurviveProjectRoundTrip() { + val r = renderer() + r.setLegendPosition(3) + r.setSeriesVisible("b", false) + r.setAxisRange(AxisPosition.LEFT_0, .0123, .0987, false) + val workspace = DpcWorkspaceState("p", listOf(DpcPageState("p", "Page", windows = listOf( + DpcWindowState("w", "MULTI_AXIS_GRAPH", "Graph", chart = r.exportWorkspaceState(), + signals = r.seriesListFlow.value.map(DpcSignalBindingState::fromSeries)) + )))) + val window = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!!.pages.single().windows.single() + val restored = renderer() + restored.restoreWorkspaceState(window.signals.map { it.toSeries() }, window.chart) + assertEquals(3, restored.buildSnapshot(600).legendPosition) + assertFalse(restored.buildSnapshot(600).seriesList.last().isVisible) + assertEquals(.0123, restored.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0).minVal) + assertTrue(restored.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_1).isAuto) + } + + @Test fun allFiveThicknessesAreDistinctPhysicalPixels() { + assertEquals(listOf(1f, 2f, 3f, 4f, 5f), (1..5).map { chartLineWidthPx(it.toFloat()) }) + assertEquals(1f, chartLineWidthPx(Float.NaN)) + } + + @Test fun rawOutOfRangeVerticesAreNotClampedAndPulseHoldsPreviousValue() { + val input = listOf(0.0 to 150.0, 1.0 to 200.0) + val linear = mutableListOf>() + forEachChartVertex(input, InterpolationMode.LINEAR) { x, y, move -> linear.add(Triple(x,y,move)) } + assertEquals(listOf(Triple(0.0,150.0,true), Triple(1.0,200.0,false)), linear) + val pulse = mutableListOf>() + forEachChartVertex(input, InterpolationMode.PWM_STEP) { x,y,move -> pulse.add(Triple(x,y,move)) } + assertEquals(listOf(Triple(0.0,150.0,true), Triple(1.0,150.0,false), Triple(1.0,200.0,false)), pulse) + } + + @Test fun nonFiniteSamplesBreakPathsAndNeverPolluteAutoBounds() { + val commands = mutableListOf() + forEachChartVertex(listOf(0.0 to 1.0, 1.0 to Double.NaN, 2.0 to 3.0), InterpolationMode.LINEAR) { _,_,move -> commands.add(move) } + assertEquals(listOf(true, true), commands) + val r = renderer() + r.addSampleSec("a", 1002.0, Double.POSITIVE_INFINITY) + val axis = r.buildSnapshot(600).axesConfig.getValue(AxisPosition.LEFT_0) + assertTrue(isValidChartAxisRange(axis.minVal, axis.maxVal)) + } + + @Test fun axisEditingRetainsPrecisionAndRejectsInvalidOrOverflowingBounds() { + for (value in listOf(.0123456789, -.00000012345, 123456.7890123)) { + assertEquals(value, chartAxisInputText(value).toDouble()) + } + for ((min,max) in listOf(Double.NaN to 1.0, 0.0 to Double.POSITIVE_INFINITY, + 1.0 to 1.0, 2.0 to 1.0, -Double.MAX_VALUE to Double.MAX_VALUE)) { + assertFalse(isValidChartAxisRange(min,max)) + val r = renderer() + val before = r.exportWorkspaceState() + r.setAxisRange(AxisPosition.LEFT_0,min,max,false) + r.setYAxisRange(min,max) + r.setAxisMinMax(AxisPosition.LEFT_0,min,max) + assertEquals(before, r.exportWorkspaceState()) + } + } + + @Test fun zoomSwitchesFromFixedPresetToAdaptiveTicksEvenWhileLive() { + val r = renderer() + r.setTimeWindow(TimeWindowPreset.MIN_1) + r.zoomTimeWindow(1000f, .4) + val s = r.buildSnapshot(600) + assertEquals(TimeWindowPreset.MANUAL, s.xAxisPreset) + val ticks = ChartAxisTicks.time(s.timeMinSec, s.timeMaxSec, 600f, s.xAxisPreset) + assertTrue(ticks.values.size > 1) + assertTrue(ticks.interval.seconds < 1.0) + } + + @Test fun smallYAxisTicksKeepDistinctDecimalValues() { + val ticks = listOf(.02, .04, .06, .08) + assertEquals(listOf("0.02", "0.04", "0.06", "0.08"), ticks.map { chartAxisTickText(it, ticks) }) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/GraphTypeTest.kt b/Android/app/src/test/java/com/dct/app/tests/GraphTypeTest.kt new file mode 100644 index 0000000..2890d84 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/GraphTypeTest.kt @@ -0,0 +1,79 @@ +package com.dct.app.tests + +import com.dct.app.data.repository.DpcProjectManager +import com.dct.app.domain.model.chart.* +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class GraphTypeTest { + @Test fun globalModeChangesPausedSnapshotWithoutLosingDataOrStyle() { + val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) + renderer.bindSignal("rpm", "RPM", AxisPosition.RIGHT_1, 0xFFAABBCC, "rpm") + renderer.setAxisRange(AxisPosition.RIGHT_1, 0.0, 100.0, false) + renderer.setSeriesThickness("rpm", 4f) + renderer.setSeriesDashStyle("rpm", 2) + renderer.addSampleSec("rpm", 1.0, 10.0) + renderer.addSampleSec("rpm", 2.0, 20.0) + renderer.pause() + val original = renderer.seriesListFlow.value.single() + val axes = renderer.exportWorkspaceState().axes + + renderer.setGraphInterpolationMode(InterpolationMode.PWM_STEP) + assertEquals(original.copy(interpolationMode = InterpolationMode.PWM_STEP), renderer.seriesListFlow.value.single()) + val pulse = renderer.renderSnapshotFlow.value.seriesList.single() + assertEquals(InterpolationMode.PWM_STEP, pulse.interpolationMode) + assertEquals(20.0, pulse.latestValue) + assertSame(original.buffer, renderer.seriesListFlow.value.single().buffer) + assertEquals(axes, renderer.exportWorkspaceState().axes) + + renderer.setGraphInterpolationMode(InterpolationMode.LINEAR) + assertEquals(original, renderer.seriesListFlow.value.single()) + assertEquals(InterpolationMode.LINEAR, renderer.renderSnapshotFlow.value.seriesList.single().interpolationMode) + } + + @Test fun newAndRestoredSignalsInheritCommonMode() { + val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) + renderer.setGraphInterpolationMode(InterpolationMode.PWM_STEP) + renderer.bindSignal("new", "New", AxisPosition.LEFT_0, 0xFF123456) + assertEquals(InterpolationMode.PWM_STEP, renderer.seriesListFlow.value.single().interpolationMode) + val savedLinear = renderer.seriesListFlow.value.single().copy(interpolationMode = InterpolationMode.LINEAR) + renderer.restoreWorkspaceState(listOf(savedLinear), null) + assertEquals(InterpolationMode.PWM_STEP, renderer.seriesListFlow.value.single().interpolationMode) + } + + @Test fun pulseAndLinearCursorValuesMatchTheirPaths() { + val points = listOf(0.0 to 0.0, 1.0 to 10.0, 2.0 to 0.0) + assertEquals(5.0, interpolateChartValue(points, 0.5, InterpolationMode.LINEAR)) + assertEquals(0.0, interpolateChartValue(points, 0.5, InterpolationMode.PWM_STEP)) + assertEquals(10.0, interpolateChartValue(points, 1.0, InterpolationMode.PWM_STEP)) + assertEquals(10.0, interpolateChartValue(points, 1.5, InterpolationMode.PWM_STEP)) + assertEquals(0.0, interpolateChartValue(points, 2.0, InterpolationMode.PWM_STEP)) + assertEquals(0.0, interpolateChartValue(points, -1.0, InterpolationMode.LINEAR)) + assertNull(interpolateChartValue(emptyList(), 0.0, InterpolationMode.LINEAR)) + assertNull(interpolateChartValue(points, Double.NaN, InterpolationMode.PWM_STEP)) + } + + @Test fun pcGraphPropertyRoundTripsAndPreservesOtherKeys() { + val raw = JSONObject("""{"graphItem_property":{"graph_linear":true,"vendor_setting":123}}""") + val project = DpcProject(rawJsonRoot = raw, graphLinear = false) + val saved = DpcProjectManager.serializeProjectToJson(project) + val property = JSONObject(saved).getJSONObject("graphItem_property") + assertFalse(property.getBoolean("graph_linear")) + assertEquals(123, property.getInt("vendor_setting")) + val restored = DpcProjectManager.parseProjectFromJson(saved, null) + assertFalse(restored.graphLinear) + val linear = DpcProjectManager.serializeProjectToJson(restored.copy(graphLinear = true)) + assertTrue(DpcProjectManager.parseProjectFromJson(linear, null).graphLinear) + } + + @Test fun importsPcPulseAndDefaultsOlderProjectsToLinear() { + assertFalse(DpcProjectManager.parseProjectFromJson("""{"graphItem_property":{"graph_linear":false}}""", null).graphLinear) + assertTrue(DpcProjectManager.parseProjectFromJson("{}", null).graphLinear) + assertTrue(DpcProjectManager.parseProjectFromJson("""{"graphItem_property":{}}""", null).graphLinear) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt b/Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt new file mode 100644 index 0000000..1817d3b --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt @@ -0,0 +1,94 @@ +package com.dct.app.tests + +import com.google.common.truth.Truth.assertThat +import com.healthmarketscience.jackcess.ColumnBuilder +import com.healthmarketscience.jackcess.CursorBuilder +import com.healthmarketscience.jackcess.DataType +import com.healthmarketscience.jackcess.Database +import com.healthmarketscience.jackcess.DatabaseBuilder +import com.healthmarketscience.jackcess.TableBuilder +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.charset.Charset + +class JackcessLoadUnitTest { + + @Test + @DisplayName("T1-MDB-01: Jackcess 4.0.11 MDB 생성, VarNif/VarLst 테이블 스키마 정의 및 데이터 CRUD 검증") + fun testJackcessMdbOperations(@TempDir tempDir: File) { + val mdbFile = File(tempDir, "test_dct_calibration.mdb") + + // 1. MDB 파일 생성 (V2000 포맷) + val db: Database = DatabaseBuilder.create(Database.FileFormat.V2000, mdbFile) + + assertThat(db).isNotNull() + + // 2. VarNif (신호 그룹 메타데이터) 테이블 생성 + val varNifTable = TableBuilder("VarNif") + .addColumn(ColumnBuilder("RECLID", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("IDX", DataType.TEXT).setLength(50).toColumn()) + .addColumn(ColumnBuilder("DESCRIP", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("USAGE", DataType.BOOLEAN).toColumn()) + .toTable(db) + + // 3. VarLst (개별 신호 정의) 테이블 생성 + val varLstTable = TableBuilder("VarLst") + .addColumn(ColumnBuilder("RECLID", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("NAME", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("ATTR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("CVS_A", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("CVS_B", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("UNIT", DataType.TEXT).setLength(50).toColumn()) + .toTable(db) + + // 4. 데이터 추가 + varNifTable.addRow(1L, "NIF_01", "엔진_센서_그룹", true) + varLstTable.addRow(1L, 0L, "EngineRPM", 4L, 1.0, 0.0, "rpm") + varLstTable.addRow(1L, 1L, "CoolantTemp", 2L, 0.1, -40.0, "°C") + + db.flush() + db.close() + + // 5. MDB 파일 재오픈 및 데이터 검증 + val reOpenedDb = DatabaseBuilder.open(mdbFile) + + val readVarNif = reOpenedDb.getTable("VarNif") + val readVarLst = reOpenedDb.getTable("VarLst") + + assertThat(readVarNif.rowCount).isEqualTo(1) + assertThat(readVarLst.rowCount).isEqualTo(2) + + val firstNifRow = readVarNif.iterator().next() + assertThat((firstNifRow["RECLID"] as Number).toLong()).isEqualTo(1L) + assertThat(firstNifRow["DESCRIP"]).isEqualTo("엔진_센서_그룹") + + // 6. 데이터 수정 (CursorBuilder 활용) + val cursor = CursorBuilder.createCursor(readVarLst) + var updated = false + while (cursor.moveToNextRow()) { + val row = cursor.currentRow + if (row["NAME"] == "EngineRPM") { + row["UNIT"] = "RPM" + cursor.updateCurrentRowFromMap(row) + updated = true + break + } + } + assertThat(updated).isTrue() + + reOpenedDb.flush() + reOpenedDb.close() + + // 7. 수정 결과 최종 확인 + val verifiedDb = DatabaseBuilder.open(mdbFile) + + val verifiedVarLst = verifiedDb.getTable("VarLst") + val rpmRow = verifiedVarLst.iterator().asSequence().first { it["NAME"] == "EngineRPM" } + assertThat(rpmRow["UNIT"]).isEqualTo("RPM") + + verifiedDb.close() + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt b/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt new file mode 100644 index 0000000..0f14d91 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt @@ -0,0 +1,1086 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DctResult +import com.dct.app.core.common.EventBus +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.InterpolationMode +import com.dct.app.domain.model.mdb.* +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.MdbRepository +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.PacketRepository +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import com.dct.app.presentation.calibration.CalibrationEditorMode +import com.dct.app.presentation.calibration.CalibrationViewModel +import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.mdi.MdiViewModel +import com.dct.app.presentation.mdi.MdiWindowType +import com.dct.app.presentation.signaltree.AddToChartRequest +import com.dct.app.presentation.signaltree.SignalTreeNode +import com.dct.app.presentation.signaltree.SignalTreeNodeType +import com.dct.app.presentation.signaltree.SignalTreeViewModel +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@DisplayName("M5 - MainViewModel 메인 뷰모델 및 오케스트레이션 단위 테스트") +class MainViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var mdbRepository: MdbRepository + private lateinit var packetRepository: PacketRepository + private lateinit var connectionState: MutableStateFlow + private lateinit var repositoryConnected: MutableStateFlow + private lateinit var repositoryStreaming: MutableStateFlow + private lateinit var chartRenderer: ChartBatchRenderer + private lateinit var treeViewModel: SignalTreeViewModel + private lateinit var mdiViewModel: MdiViewModel + private lateinit var calibrationViewModel: CalibrationViewModel + private lateinit var eventBus: EventBus + private lateinit var dispatchers: TestDispatcherProvider + + private lateinit var viewModel: MainViewModel + private lateinit var sampleDb: MdbDatabase + + @BeforeEach + fun setUp() { + Dispatchers.setMain(testDispatcher) + mdbRepository = mockk(relaxed = true) + packetRepository = mockk(relaxed = true) + dispatchers = TestDispatcherProvider(testDispatcher) + eventBus = EventBus() + + val packetFlow = MutableSharedFlow(extraBufferCapacity = 8) + connectionState = MutableStateFlow(DeviceConnectionState.DISCONNECTED) + repositoryConnected = MutableStateFlow(false) + repositoryStreaming = MutableStateFlow(false) + every { packetRepository.packetStream } returns packetFlow + every { packetRepository.connectionState } returns connectionState + every { packetRepository.isConnected } returns repositoryConnected + every { packetRepository.isStreaming } returns repositoryStreaming + coEvery { packetRepository.configureMeasurement(any()) } returns DctResult.Success(Unit) + coEvery { packetRepository.startStreaming(any()) } returns DctResult.Success(Unit) + coEvery { packetRepository.stopStreaming() } returns DctResult.Success(Unit) + coEvery { packetRepository.disconnect() } returns DctResult.Success(Unit) + + chartRenderer = ChartBatchRenderer(dispatchers) + treeViewModel = SignalTreeViewModel(dispatchers) + mdiViewModel = MdiViewModel(dispatchers) + calibrationViewModel = CalibrationViewModel(mdbRepository, dispatchers) + + val sig1 = SignalDefinition(1, 1, "Ta_Motor_Speed", MdbAttrType.ATTR_2_UINT16, 0.25, 0.0, "RPM", 0xFF0052CCL, 0.0, 8000.0) + val sig2 = SignalDefinition(1, 2, "Ta_Inverter_Temp", MdbAttrType.ATTR_1_INT8, 1.0, -40.0, "°C", 0xFFBA1A1AL, -40.0, 150.0) + val group = SignalGroup(1, "G1", "모터 그룹", true, signals = listOf(sig1, sig2)) + val const1 = MdbConstant(1, "K_MAX_TORQUE", 350.0, 0x1000L, 0.0, 500.0, MdbAttrType.ATTR_2_UINT16) + + sampleDb = MdbDatabase("Sample.mdb", listOf(group), listOf(const1), emptyList()) + + coEvery { mdbRepository.openDatabase(any()) } returns DctResult.Success(MdbDatabaseInfo("Sample.mdb", "/Sample.mdb", "1MB", "V2000", 1, emptyList())) + every { mdbRepository.getCurrentDatabase() } returns sampleDb + + viewModel = MainViewModel( + mdbRepository = mdbRepository, + chartRenderer = chartRenderer, + treeViewModel = treeViewModel, + mdiViewModel = mdiViewModel, + calibrationViewModel = calibrationViewModel, + eventBus = eventBus, + dispatchers = dispatchers, + wifiSocketManager = mockk(relaxed = true), // Lifecycle tests must not launch real asynchronous TCP attempts. + packetRepository = packetRepository + ) + // Rendering cadence is not under test; keep virtual time from advancing forever. + chartRenderer.stop() + } + + @Test fun rawMdbBytesRemainAvailableWithoutPhysicalInversion() = runTest { + sampleDb = sampleDb.copy(groups = listOf(sampleDb.groups.single().copy(signals = listOf( + sampleDb.allSignals.first().copy(cvsA = 0.0, cvsB = 7.0) + )))) + every { mdbRepository.getCurrentDatabase() } returns sampleDb + viewModel.openMdbFromFile(File("Sample.mdb")) + val method = MainViewModel::class.java.getDeclaredMethod("decodeMdbRecord", Int::class.javaPrimitiveType, ByteArray::class.java, MutableMap::class.java) + method.isAccessible = true + val values = mutableMapOf() + assertNull(method.invoke(viewModel, 1, byteArrayOf(0x34, 0x12), values)) + assertEquals(4660.0, values["raw_sig_1_1"]) + assertEquals(7.0, values["sig_1_1"]) + assertNotNull(method.invoke(viewModel, 1, byteArrayOf(0x12), values)) + assertEquals(4660.0, values["raw_sig_1_1"]) + } + + @Test fun descriptionEditingAndWindowPreferencesSurviveSaveAndRestore() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(mdbFileName = "Sample.mdb")) + viewModel.updateParameterDescription("constant", "K_MAX_TORQUE", "새 설명") + assertEquals("새 설명", viewModel.uiState.value.mdbDatabase!!.constants.single().description) + assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) + coVerify(exactly = 0) { packetRepository.writeConstant(any(), any()) } + val id = viewModel.addGraphWindow() + val prefs = mapOf("search" to "RPM", "width:name" to "200.0") + mdiViewModel.updateWindowContentState(id, prefs) + val saved = viewModel.snapshotWorkspaceForSave() + viewModel.restoreProjectWorkspace(saved) + assertEquals(prefs, mdiViewModel.uiState.value.windows.first { it.id == id }.config.contentState) + val descriptions = viewModel.currentProject.value!!.parameterDescriptions + assertEquals("새 설명", descriptions["constant:K_MAX_TORQUE"]) + viewModel.getChartRenderer(id).stop() + chartRenderer.stop() + } + + @Test fun alphaIdsFromDifferentSourcesDoNotCollapseOrDeleteEachOther() { + val id = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)!! + viewModel.addAlphanumericalSignal(id, "can_1") + viewModel.addAlphanumericalSignal(id, "a2l_1") + assertEquals(listOf("can_1", "a2l_1"), mdiViewModel.uiState.value.windows.first { it.id == id }.config.targetSignals) + viewModel.removeAlphanumericalSignal(id, "can_1") + assertEquals(listOf("a2l_1"), mdiViewModel.uiState.value.windows.first { it.id == id }.config.targetSignals) + } + + @Test fun oldWindowDescriptionCallbackCannotModifyNewProject() { + val revision = viewModel.workspaceRevision.value + viewModel.resetWorkspaceForNewProject() + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject()) + val modules = viewModel.sensorModules.value + viewModel.updateParameterDescription("constant", "K", "old", revision) + viewModel.updateSensorModuleDescription(0, 0, "old", revision) + assertTrue(viewModel.currentProject.value!!.parameterDescriptions.isEmpty()) + assertEquals(modules, viewModel.sensorModules.value) + } + + @Test fun compensationDescriptionsSaveToDpcAndRestoreIncludingEmptyText( + @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path + ) { + val a = CompensationMap(CompensationMapType.TYPE_A, 1, "Shared", 0, MdbAttrType.ATTR_3_INT16, + 0.0, 100.0, "X", 0, MdbAttrType.ATTR_3_INT16, 0.1234, 0.5678, + description = "original A", points = listOf(MapPoint(0, 1, 2, 0.6912))) + val b = a.copy(type = CompensationMapType.TYPE_B, description = "original B") + sampleDb = sampleDb.copy(compensationMaps = listOf(a, b)) + every { mdbRepository.getCurrentDatabase() } returns sampleDb + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject( + projectFileName = "Descriptions.dpc", mdbFileName = "Sample.mdb")) + viewModel.openMdbFromFile(File("Sample.mdb")) + viewModel.updateParameterDescription("map_TYPE_A", "Shared", "수정한 설명 A") + viewModel.updateParameterDescription("map_TYPE_B", "Shared", "") + val context = mockk(relaxed = true) + mockkObject(com.dct.app.data.repository.DpcProjectManager) + try { + every { com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(any()) } returns directory.toFile() + assertTrue(viewModel.saveActiveProject(context, showFeedback = false)) + val file = directory.resolve("Configuration/Descriptions.dpc").toFile() + assertTrue(file.exists()) + val restored = com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(file)!! + assertEquals("수정한 설명 A", restored.parameterDescriptions["map_TYPE_A:Shared"]) + assertEquals("", restored.parameterDescriptions["map_TYPE_B:Shared"]) + viewModel.resetWorkspaceForNewProject() + viewModel.setCurrentProject(restored) + viewModel.openMdbFromFile(File("Sample.mdb")) + val maps = viewModel.uiState.value.mdbDatabase!!.compensationMaps + assertEquals("수정한 설명 A", maps[0].description) + assertEquals("", maps[1].description) + assertEquals(a.points, maps[0].points) + assertEquals(a.cvsB, maps[0].cvsB) + assertEquals("original A", sampleDb.compensationMaps[0].description) + } finally { + unmockkObject(com.dct.app.data.repository.DpcProjectManager) + } + } + + @Test fun constantResultCallbackWaitsForAckAndReportsBusyAndNack() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + connectionState.value = DeviceConnectionState.READY + val pending = kotlinx.coroutines.CompletableDeferred>() + coEvery { packetRepository.writeConstant(any(), any()) } coAnswers { pending.await() } + val results = mutableListOf() + viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0, onComplete = { results.add(it) }) + assertTrue(viewModel.isCalibrationBusy.value) + assertTrue(results.isEmpty()) + var busyError: String? = null + viewModel.readMdbConstant("K_MAX_TORQUE", onComplete = { busyError = it }) + assertNotNull(busyError) + pending.complete(DctResult.Failure(com.dct.app.core.error.DctError.Unknown("NACK"))) + assertFalse(viewModel.isCalibrationBusy.value) + assertEquals(1, results.size) + assertTrue(results.single()!!.contains("NACK")) + assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) + } + + @Test fun constantReadCompletionSeesUpdatedValueAndOldRevisionCannotWrite() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + connectionState.value = DeviceConnectionState.READY + coEvery { packetRepository.readConstant(any(), any()) } returns DctResult.Success(byteArrayOf(1, 0x90.toByte())) + coEvery { mdbRepository.updateConstant(any(), any()) } returns DctResult.Success(Unit) + var completed = false + viewModel.readMdbConstant("K_MAX_TORQUE", onComplete = { + assertNull(it) + assertEquals(400.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) + completed = true + }) + assertTrue(completed) + assertFalse(viewModel.isCalibrationBusy.value) + val revision = viewModel.workspaceRevision.value + viewModel.resetWorkspaceForNewProject() + var staleError: String? = null + viewModel.updateMdbConstantValue("K_MAX_TORQUE", 500.0, { staleError = it }, revision) + assertNotNull(staleError) + coVerify(exactly = 0) { packetRepository.writeConstant(any(), any()) } + } + + @Test fun constantDescriptionAndDisplayPreferencesPersistToDpc( + @org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path + ) { + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(projectFileName = "Constant.dpc", mdbFileName = "Sample.mdb")) + viewModel.openMdbFromFile(File("Sample.mdb")) + val id = mdiViewModel.addWindow(MdiWindowType.CONSTANT_SIGNAL_LIST)!! + val preferences = mapOf("expanded" to "true", "checked_constants" to "1", "only_checked" to "true", "width:desc" to "245.0", "search" to "Torque") + mdiViewModel.updateWindowContentState(id, preferences) + viewModel.updateParameterDescription("constant", "K_MAX_TORQUE", "프로젝트 설명") + mockkObject(com.dct.app.data.repository.DpcProjectManager) + try { + every { com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(any()) } returns directory.toFile() + assertTrue(viewModel.saveActiveProject(mockk(relaxed = true), showFeedback = false)) + val file = directory.resolve("Configuration/Constant.dpc").toFile() + val restored = com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(file)!! + assertEquals("프로젝트 설명", restored.parameterDescriptions["constant:K_MAX_TORQUE"]) + assertEquals(preferences, restored.workspace!!.pages.flatMap { it.windows }.single { it.id == id }.contentState) + viewModel.updateParameterDescription("constant", "K_MAX_TORQUE", "") + assertTrue(viewModel.saveActiveProject(mockk(relaxed = true), showFeedback = false)) + assertEquals("", com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(file)!!.parameterDescriptions["constant:K_MAX_TORQUE"]) + assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) + coVerify(exactly = 0) { packetRepository.writeConstant(any(), any()) } + } finally { unmockkObject(com.dct.app.data.repository.DpcProjectManager) } + } + + @AfterEach + fun tearDown() { + viewModel.stopMeasure() + viewModel.resetWorkspaceForNewProject() + chartRenderer.stop() + Dispatchers.resetMain() + } + + @Test + fun constantWriteDoesNotChangeUiBeforeAckOrAfterNack() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + connectionState.value = DeviceConnectionState.READY + val pending = kotlinx.coroutines.CompletableDeferred>() + coEvery { packetRepository.writeConstant(any(), any()) } coAnswers { pending.await() } + viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) + assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) + coVerify(exactly = 0) { mdbRepository.updateConstant(any(), any()) } + pending.complete(DctResult.Failure(com.dct.app.core.error.DctError.Unknown("NACK"))) + assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) + assertTrue(viewModel.uiState.value.statusMessage!!.contains("실패")) + coVerify(exactly = 0) { mdbRepository.updateConstant(any(), any()) } + } + + @Test + fun constantWriteUsesBigEndianAndPersistsOnlyOnSuccess() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + connectionState.value = DeviceConnectionState.READY + val bytes = slot() + coEvery { packetRepository.writeConstant(0x1000, capture(bytes)) } returns DctResult.Success(Unit) + coEvery { mdbRepository.updateConstant("K_MAX_TORQUE", 400.0) } returns DctResult.Success(Unit) + viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) + assertArrayEquals(byteArrayOf(1,0x90.toByte()), bytes.captured) + assertEquals(400.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) + coVerify(exactly = 1) { mdbRepository.updateConstant("K_MAX_TORQUE", 400.0) } + } + + @Test + fun calibrationRejectsDisconnectedMeasuringAndInvalidValues() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) + connectionState.value = DeviceConnectionState.MEASURING + viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) + connectionState.value = DeviceConnectionState.READY + for (value in listOf(Double.NaN, 501.0, 400.5)) viewModel.updateMdbConstantValue("K_MAX_TORQUE", value) + coVerify(exactly = 0) { packetRepository.writeConstant(any(), any()) } + assertEquals(350.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) + } + + @Test + fun projectSwitchDuringConstantWriteCannotUpdateNewProject() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + connectionState.value = DeviceConnectionState.READY + val pending = kotlinx.coroutines.CompletableDeferred>() + coEvery { packetRepository.writeConstant(any(), any()) } coAnswers { pending.await() } + viewModel.updateMdbConstantValue("K_MAX_TORQUE", 400.0) + viewModel.resetWorkspaceForNewProject() + pending.complete(DctResult.Success(Unit)) + assertNull(viewModel.uiState.value.mdbDatabase) + coVerify(exactly = 0) { mdbRepository.updateConstant(any(), any()) } + } + + @Test + fun constantReadUpdatesUiAndMdbFromDeviceBytes() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + connectionState.value = DeviceConnectionState.READY + coEvery { packetRepository.readConstant(0x1000, 2) } returns DctResult.Success(byteArrayOf(1,0x90.toByte())) + coEvery { mdbRepository.updateConstant("K_MAX_TORQUE", 400.0) } returns DctResult.Success(Unit) + viewModel.readMdbConstant("K_MAX_TORQUE") + assertEquals(400.0, viewModel.uiState.value.mdbDatabase!!.constants.single().data) + } + + @Test + fun graphTypeAppliesAcrossPagesAndSurvivesProjectReloadAndNewWindows() { + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(projectFileName = "GraphType.dpc")) + val firstId = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! + val first = viewModel.getChartRenderer(firstId) + first.bindSignal("rpm", "RPM", AxisPosition.LEFT_0, 0xFF123456, "rpm") + first.addSampleSec("rpm", 1.0, 42.0) + val originalBuffer = first.seriesListFlow.value.single().buffer + viewModel.addPage() + val secondId = mdiViewModel.addWindow(MdiWindowType.MULTI_AXIS_GRAPH)!! + val second = viewModel.getChartRenderer(secondId) + second.bindSignal("temp", "Temperature", AxisPosition.RIGHT_0, 0xFFABCDEF, "C") + + viewModel.setGraphInterpolationMode(InterpolationMode.PWM_STEP) + assertEquals(InterpolationMode.PWM_STEP, viewModel.uiState.value.graphInterpolationMode) + assertFalse(viewModel.currentProject.value!!.graphLinear) + for (renderer in listOf(first, second)) { + assertEquals(InterpolationMode.PWM_STEP, renderer.renderSnapshotFlow.value.seriesList.single().interpolationMode) + } + assertSame(originalBuffer, first.seriesListFlow.value.single().buffer) + assertEquals(42.0, first.seriesListFlow.value.single().buffer.getLatestValue()) + + val saved = viewModel.currentProject.value!!.copy(workspace = viewModel.snapshotWorkspaceForSave()) + assertTrue(saved.workspace!!.pages.flatMap { it.windows }.flatMap { it.signals } + .all { it.interpolationMode == InterpolationMode.PWM_STEP }) + val json = com.dct.app.data.repository.DpcProjectManager.serializeProjectToJson(saved) + val loaded = com.dct.app.data.repository.DpcProjectManager.parseProjectFromJson(json, null) + viewModel.resetWorkspaceForNewProject() + viewModel.setCurrentProject(loaded) + viewModel.restoreProjectWorkspace(loaded.workspace) + for (id in listOf(firstId, secondId)) { + assertEquals(InterpolationMode.PWM_STEP, viewModel.getChartRenderer(id).seriesListFlow.value.single().interpolationMode) + } + val newId = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! + val newRenderer = viewModel.getChartRenderer(newId) + newRenderer.bindSignal("new", "New", AxisPosition.LEFT_0, 0xFF111111) + assertEquals(InterpolationMode.PWM_STEP, newRenderer.seriesListFlow.value.single().interpolationMode) + viewModel.setGraphInterpolationMode(InterpolationMode.LINEAR) + assertTrue(viewModel.currentProject.value!!.graphLinear) + assertEquals(InterpolationMode.LINEAR, newRenderer.renderSnapshotFlow.value.seriesList.single().interpolationMode) + } + + @Test + fun draggedWindowMovesOnceWithRendererSamplesAndSavedLayoutIntact() { + val source = viewModel.activePageId.value + val graph = mdiViewModel.addWindow(MdiWindowType.MULTI_AXIS_GRAPH, width = 640f, height = 400f)!! + val renderer = viewModel.getChartRenderer(graph) + renderer.bindSignal("rpm", "RPM", AxisPosition.RIGHT_1, 0xFF123456, "rpm") + renderer.setAxisRange(AxisPosition.RIGHT_1, 0.0, 9000.0, false) + renderer.addSampleSec("rpm", 1.0, 1234.0) + val buffer = renderer.seriesListFlow.value.single().buffer + val destination = viewModel.addPage() + val other = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)!! + viewModel.switchPage(source) + assertTrue(viewModel.moveWindowToPageAndSwitch(graph, destination, 850f, 360f)) + assertEquals(destination, viewModel.activePageId.value) + assertEquals(graph, mdiViewModel.uiState.value.activeWindowId) + assertSame(renderer, viewModel.getChartRenderer(graph)) + assertSame(buffer, renderer.seriesListFlow.value.single().buffer) + assertEquals(1234.0, buffer.getLatestValue()) + val moved = mdiViewModel.uiState.value.windows.first { it.id == graph } + assertEquals(850f, moved.offsetX) + assertEquals(360f, moved.offsetY) + assertEquals(640f, moved.width) + assertEquals(400f, moved.height) + assertTrue(mdiViewModel.uiState.value.windows.any { it.id == other }) + val saved = viewModel.snapshotWorkspaceForSave() + assertEquals(1, saved.pages.flatMap { it.windows }.count { it.id == graph }) + assertTrue(saved.pages.first { it.id == source }.windows.isEmpty()) + assertEquals("rpm", saved.pages.first { it.id == destination }.windows.first { it.id == graph }.signals.single().signalId) + viewModel.switchPage(source) + assertTrue(mdiViewModel.uiState.value.windows.isEmpty()) + } + + @Test + fun dragCanCrossMultiplePagesAndReturnToOriginalBoundsOnCancel() { + val source = viewModel.activePageId.value + val windowId = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL, offsetX = 30f, offsetY = 40f)!! + val second = viewModel.addPage() + val third = viewModel.addPage() + viewModel.switchPage(source) + assertTrue(viewModel.moveWindowToPageAndSwitch(windowId, second, 60f, 80f)) + assertTrue(viewModel.moveWindowToPageAndSwitch(windowId, third, 90f, 120f)) + assertTrue(viewModel.moveWindowToPageAndSwitch(windowId, source, 30f, 40f)) + val saved = viewModel.snapshotWorkspaceForSave() + assertEquals(source, saved.activePageId) + assertEquals(1, saved.pages.flatMap { it.windows }.count { it.id == windowId }) + assertEquals(30f, mdiViewModel.uiState.value.windows.single().offsetX) + assertEquals(40f, mdiViewModel.uiState.value.windows.single().offsetY) + } + + @Test + fun invalidOrFullDragDestinationDoesNotChangePageOrWindow() { + val source = viewModel.activePageId.value + val windowId = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! + val destination = viewModel.addPage() + repeat(mdiViewModel.uiState.value.maxWindowsLimit) { mdiViewModel.addWindow(MdiWindowType.GRAPH) } + viewModel.switchPage(source) + val before = mdiViewModel.uiState.value + assertFalse(viewModel.moveWindowToPageAndSwitch(windowId, "missing", 0f, 0f)) + assertFalse(viewModel.moveWindowToPageAndSwitch(windowId, destination, Float.NaN, 0f)) + assertFalse(viewModel.moveWindowToPageAndSwitch(windowId, destination, 0f, 0f)) + assertFalse(viewModel.moveWindowToPageAndSwitch(windowId, source, 0f, 0f)) + assertEquals(source, viewModel.activePageId.value) + assertEquals(before, mdiViewModel.uiState.value) + } + + @Test + fun newWorkspaceResetsGraphTypeToLinear() { + viewModel.setGraphInterpolationMode(InterpolationMode.PWM_STEP) + viewModel.resetWorkspaceForNewProject() + assertEquals(InterpolationMode.LINEAR, viewModel.uiState.value.graphInterpolationMode) + chartRenderer.bindSignal("new", "New", AxisPosition.LEFT_0, 0xFF111111) + assertEquals(InterpolationMode.LINEAR, chartRenderer.seriesListFlow.value.single().interpolationMode) + } + + @Test + fun workspaceSnapshotUsesLatestActiveWindowsAndInactiveGraphSettings() { + val graph = mdiViewModel.addWindow(MdiWindowType.GRAPH)!! + val renderer = viewModel.getChartRenderer(graph) + renderer.bindSignal("sig_1_1", "RPM", AxisPosition.LEFT_0, 0xFF123456L, "rpm") + renderer.setSeriesDashStyle("sig_1_1", 3) + renderer.setSeriesVisible("sig_1_1", false) + renderer.setLegendPosition(2) + renderer.setAxisRange(AxisPosition.LEFT_0, 10.0, 500.0, false) + mdiViewModel.updateWindowBounds(graph, 850f, 250f, 600f, 450f) + val revision = viewModel.workspaceRevision.value + val release = viewModel.registerPageViewport("page_1", revision) { 240f to 80f } + val second = viewModel.addPage() + release() + val alpha = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL, + config = com.dct.app.presentation.mdi.MdiWindowConfig( + targetSignals = listOf("sig_1_1"), isTileMode = false, + hiddenSignalIds = setOf("RPM"), highlightColors = mapOf("RPM" to 0xFFABCDEF) + ))!! + val saved = viewModel.snapshotWorkspaceForSave() + assertEquals(second, saved.activePageId) + val first = saved.pages.first() + assertEquals(240f, first.scrollX) + assertEquals(graph, first.activeWindowId) + assertEquals(850f, first.windows.single().x) + assertEquals(3, first.windows.single().signals.single().dashStyle) + assertFalse(first.windows.single().signals.single().isVisible) + assertEquals(2, first.windows.single().chart!!.legendPosition) + + viewModel.resetWorkspaceForNewProject() + viewModel.restoreProjectWorkspace(saved) + assertEquals(alpha, mdiViewModel.uiState.value.activeWindowId) + assertFalse(mdiViewModel.uiState.value.windows.single().config.isTileMode) + viewModel.switchPage("page_1") + assertEquals(graph, mdiViewModel.uiState.value.activeWindowId) + assertEquals(500.0, viewModel.getChartRenderer(graph).exportWorkspaceState().axes.first { it.position == AxisPosition.LEFT_0 }.maxVal) + assertTrue(viewModel.getChartRenderer(graph).seriesListFlow.value.single().buffer.getLatestTimestamp() == null) + } + + @Test + fun savedEmptyPagesStayEmptyAndOldViewportCannotAffectNewProject() { + viewModel.openMdbFromFile(File("Sample.mdb")) + val release = viewModel.registerPageViewport("page_1", viewModel.workspaceRevision.value) { 999f to 999f } + val workspace = com.dct.app.domain.model.project.DpcWorkspaceState("page_1", listOf( + com.dct.app.domain.model.project.DpcPageState("page_1", "Empty 1"), + com.dct.app.domain.model.project.DpcPageState("page_2", "Empty 2") + )) + viewModel.restoreProjectWorkspace(workspace) + release() + viewModel.switchPage("page_2") + viewModel.switchPage("page_1") + assertTrue(mdiViewModel.uiState.value.windows.isEmpty()) + assertEquals(0f, viewModel.snapshotWorkspaceForSave().pages.first().scrollX) + } + + @Test + fun projectLayoutRestoresOnlyAfterSourceLoadCompletes(@org.junit.jupiter.api.io.TempDir directory: java.nio.file.Path) { + val mdb = directory.resolve("Sample.mdb").toFile().apply { writeText("fixture") } + val projectFile = directory.resolve("workspace.dpc").toFile() + val workspace = com.dct.app.domain.model.project.DpcWorkspaceState("page_2", listOf( + com.dct.app.domain.model.project.DpcPageState("page_1", "Empty"), + com.dct.app.domain.model.project.DpcPageState("page_2", "Saved", windows = listOf( + com.dct.app.domain.model.project.DpcWindowState("saved_graph", "GRAPH", "Saved graph") + )) + )) + val project = com.dct.app.domain.model.project.DpcProject( + projectFileName = "workspace.dpc", localMdbPath = mdb.absolutePath, + mdbFileName = "Sample.mdb", workspace = workspace + ) + val gate = kotlinx.coroutines.CompletableDeferred() + coEvery { mdbRepository.openDatabase(mdb) } coAnswers { + gate.await() + DctResult.Success(MdbDatabaseInfo("Sample.mdb", mdb.absolutePath, "1MB", "V2000", 1, emptyList())) + } + val context = mockk(relaxed = true) + mockkObject(com.dct.app.data.repository.DpcProjectManager) + try { + every { com.dct.app.data.repository.DpcProjectManager.loadProjectFromFile(projectFile) } returns project + viewModel.openProjectFromFile(context, projectFile) + assertTrue(mdiViewModel.uiState.value.windows.none { it.id == "saved_graph" }) + assertFalse(viewModel.saveActiveProject(context, showFeedback = false)) + gate.complete(Unit) + assertEquals("page_2", viewModel.activePageId.value) + assertEquals(listOf("saved_graph"), mdiViewModel.uiState.value.windows.map { it.id }) + assertNotNull(viewModel.uiState.value.mdbDatabase) + } finally { + gate.complete(Unit) + unmockkObject(com.dct.app.data.repository.DpcProjectManager) + } + } + + @Test + fun autoSaveIncludesWorkspaceEvenWithoutSourceFiles() { + val context = mockk(relaxed = true) + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(projectFileName = "Layout.dpc")) + val window = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)!! + mdiViewModel.updateWindowBounds(window, 320f, 180f, 400f, 300f) + val captured = slot() + mockkObject(com.dct.app.data.repository.DpcProjectManager) + try { + every { com.dct.app.data.repository.DpcProjectManager.ensureDctWorkDirectories(context) } returns File("DCT Work") + every { com.dct.app.data.repository.DpcProjectManager.getDefaultBaseFolder(context) } returns "DCT Work" + every { com.dct.app.data.repository.DpcProjectManager.getDefaultMeasuredFolder(context) } returns "DCT Work/Data" + every { com.dct.app.data.repository.DpcProjectManager.saveProject(context, capture(captured)) } returns true + assertTrue(viewModel.autoSaveOnExit(context)) + assertEquals(window, captured.captured.workspace!!.pages.single().windows.single().id) + assertEquals(320f, captured.captured.workspace!!.pages.single().windows.single().x) + } finally { + unmockkObject(com.dct.app.data.repository.DpcProjectManager) + } + } + + @Test + @DisplayName("MDB 파일을 로드하고 대시보드 타일을 생성해야 한다") + fun testInitLoadsSampleMdb() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + + val state = viewModel.uiState.value + assertNotNull(state.mdbDatabase) + assertEquals("Sample.mdb", state.mdbFileName) + assertEquals(2, state.dashboardTiles.size) + assertEquals("Ta_Motor_Speed", state.dashboardTiles[0].signalName) + } + + @Test + @DisplayName("READY 장비에서만 계측 시작, 중지 및 토글 상태 전이가 동작해야 한다") + fun testMeasurementLifecycle() { + assertFalse(viewModel.uiState.value.isMeasuring) + viewModel.startMeasure(30) + assertFalse(viewModel.uiState.value.isMeasuring) + + connectionState.value = DeviceConnectionState.READY + repositoryConnected.value = true + viewModel.startMeasure(30) + assertTrue(viewModel.uiState.value.isMeasuring) + // Configuration runs in the Wi-Fi preparation workflow, not each start button press. + coVerify(exactly = 0) { packetRepository.configureMeasurement(any()) } + coVerify { packetRepository.startStreaming(30) } + + viewModel.toggleMeasure() + assertFalse(viewModel.uiState.value.isMeasuring) + coVerify { packetRepository.stopStreaming() } + } + + @Test + @DisplayName("신호 트리에서 차트 바인딩 요청 시 ChartBatchRenderer에 시리즈가 추가되어야 한다") + fun testAddToChartHandling() { + val req = AddToChartRequest( + signalName = "Ta_Motor_Speed", + signalId = "sig_1_1", + color = 0xFF0052CCL, + unit = "RPM", + axisPosition = AxisPosition.LEFT_1 + ) + + viewModel.handleAddToChart(req) + + val seriesList = chartRenderer.seriesListFlow.value + assertEquals(1, seriesList.size) + assertEquals("Ta_Motor_Speed", seriesList[0].signalName) + assertEquals(AxisPosition.LEFT_1, seriesList[0].axisPosition) + } + + @Test + @DisplayName("MDB 신호 색상 변경 시 목록, 타일, 기본/윈도우 그래프에 즉시 동기화되어야 한다") + fun testSignalColorSynchronizesAcrossViews() { + viewModel.openMdbFromFile(File("Sample.mdb")) + val signal = requireNotNull(viewModel.uiState.value.mdbDatabase) + .allSignals.first { it.name == "Ta_Motor_Speed" } + val graphWindowId = viewModel.addGraphWindow() + + viewModel.handleAddToChart( + signalId = "sig_${signal.reclid}_${signal.idx}", + signalName = signal.name, + colorLong = signal.color, + unit = signal.unit, + targetWindowId = graphWindowId + ) + + val changedColor = 0x00A5FFL + viewModel.updateMdbSignalColor(signal = signal, color = changedColor) + + assertEquals( + changedColor, + viewModel.uiState.value.mdbDatabase!!.allSignals.first { it.name == signal.name }.color + ) + assertEquals( + changedColor, + viewModel.uiState.value.dashboardTiles.first { it.signalId == "sig_${signal.reclid}_${signal.idx}" }.color + ) + assertEquals( + changedColor, + chartRenderer.seriesListFlow.value.first { it.signalId == "sig_${signal.reclid}_${signal.idx}" }.color + ) + assertEquals( + changedColor, + viewModel.getChartRenderer(graphWindowId).seriesListFlow.value + .first { it.signalId == "sig_${signal.reclid}_${signal.idx}" }.color + ) + assertEquals( + changedColor, + mdiViewModel.uiState.value.windows.first { it.id == graphWindowId } + .config.boundSignals.first { it.signalId == "sig_${signal.reclid}_${signal.idx}" }.color + ) + } + + @Test + @DisplayName("신호 트리에서 상수를 선택하면 CalibrationViewModel의 상수 모드로 자동 전환되어야 한다") + fun testTreeSelectionRoutingToCalibration() { + val constNode = SignalTreeNode( + id = "con_1", + name = "K_MAX_TORQUE", + type = SignalTreeNodeType.CONSTANT, + rawConstant = sampleDb.constants[0] + ) + + treeViewModel.selectNode(constNode) + + assertEquals(CalibrationEditorMode.CONSTANT, calibrationViewModel.uiState.value.mode) + assertEquals("K_MAX_TORQUE", calibrationViewModel.uiState.value.constantState?.constant?.name) + } + + @Test + @DisplayName("saveCurrentMdb 실행 시 정상 저장 상태가 반환되어야 한다") + fun testSaveCurrentMdb() { + viewModel.openMdbFromFile(File("Sample.mdb")) + val result = viewModel.saveCurrentMdb() + + assertTrue(result is DctResult.Success) + assertNotNull(viewModel.uiState.value.statusMessage) + } + + @Test + @DisplayName("CAN DBC 신호 StateFlow가 초기 상태에서는 빈 리스트를 방출해야 한다") + fun testCanSignalsEmission() { + val signals = viewModel.canSignals.value + assertEquals(0, signals.size) + assertTrue(signals.isEmpty()) + } + + @Test + @DisplayName("openMdbFromUri 호출 시 mdbDatabase 갱신 및 SignalTreeViewModel 동기화가 정상 수행되어야 한다") + fun testOpenMdbFromUriSyncsTreeAndDatabase() = runTest { + val mockContext = mockk(relaxed = true) + val mockUri = mockk(relaxed = true) + + val customDbInfo = MdbDatabaseInfo( + fileName = "Custom_EV_DCT.mdb", + filePath = "/sdcard/Custom_EV_DCT.mdb", + fileSizeFormatted = "1.2 MB", + fileFormat = "V2000", + tableCount = 5, + tables = emptyList() + ) + + val customSig = SignalDefinition(2, 1, "Ta_Battery_SOC", MdbAttrType.ATTR_0_UINT8, 0.5, 0.0, "%", 0xFF009A4CL, 0.0, 100.0) + val customGroup = SignalGroup(2, "BMS", "배터리 그룹", true, signals = listOf(customSig)) + val customDb = MdbDatabase("Custom_EV_DCT.mdb", listOf(customGroup), emptyList(), emptyList()) + + coEvery { mdbRepository.openDatabase(mockContext, mockUri) } returns DctResult.Success(customDbInfo) + every { mdbRepository.getCurrentDatabase() } returns customDb + + viewModel.openMdbFromUri(mockContext, mockUri) + + val state = viewModel.uiState.value + assertEquals("Custom_EV_DCT.mdb", state.mdbFileName) + assertEquals(customDb, state.mdbDatabase) + assertFalse(state.isMdbLoading) + } + + @Test + @DisplayName("계측 시작 시 liveTelemetryValues 맵이 초기화되어야 한다") + fun testLiveTelemetryValuesCalculation() = runTest { + viewModel.openMdbFromFile(File("Sample.mdb")) + connectionState.value = DeviceConnectionState.READY + viewModel.startMeasure(30) + + val telemetry = viewModel.liveTelemetryValues.value + assertNotNull(telemetry) + + viewModel.stopMeasure() + } + + @Test + @DisplayName("saveCurrentMdb(context) 호출 시 MdbRepository.saveToOriginal이 호출되어야 한다") + fun testSaveCurrentMdbWithContext() = runTest { + val mockContext = mockk(relaxed = true) + coEvery { mdbRepository.saveToOriginal(mockContext) } returns DctResult.Success(Unit) + + viewModel.openMdbFromFile(File("Sample.mdb")) + val result = viewModel.saveCurrentMdb(mockContext) + + assertTrue(result is DctResult.Success) + coVerify { mdbRepository.saveToOriginal(mockContext) } + } + + @Test + @DisplayName("멀티 페이지 탭 추가/전환 시 프로젝트 전역 차트 시리즈 및 데이터가 공유/유지되어야 한다") + fun testMultiPageWorkspaceTabManagement() { + val initialPages = viewModel.pages.value + assertEquals(1, initialPages.size) + assertEquals("page_1", viewModel.activePageId.value) + + viewModel.handleAddToChart("sig_1_1", "Ta_Motor_Speed", 0xFF0052CCL, "RPM", isCan = false) + assertEquals(1, chartRenderer.seriesListFlow.value.size) + + val page2Id = viewModel.addPage() + assertEquals(page2Id, viewModel.activePageId.value) + // 탭이 바뀌어도 한 프로젝트를 공유하므로 기존 차트 시리즈와 데이터는 유지됨 + assertEquals(1, chartRenderer.seriesListFlow.value.size) + + viewModel.handleAddToChart("can_5", "CF_Clu_DrivingSpeed", 0xFF00796BL, "km/h", isCan = true) + assertEquals(2, chartRenderer.seriesListFlow.value.size) + + viewModel.switchPage("page_1") + assertEquals("page_1", viewModel.activePageId.value) + assertEquals(2, chartRenderer.seriesListFlow.value.size) + assertTrue(chartRenderer.seriesListFlow.value.any { it.signalName == "Ta_Motor_Speed" }) + assertTrue(chartRenderer.seriesListFlow.value.any { it.signalName == "CF_Clu_DrivingSpeed" }) + + val page3Id = viewModel.addPage() + assertEquals(3, viewModel.pages.value.size) + + viewModel.removePage(page3Id) + assertEquals(2, viewModel.pages.value.size) + } + + @Test + @DisplayName("신호 추가 시 좌측 다중 축(LEFT_0 ~ LEFT_3)으로 순차 자동 할당되어야 한다") + fun testDynamicChartAxisAllocation() { + viewModel.handleAddToChart("sig_1", "MDB_1", 0xFF0052CCL, "V", isCan = false) + viewModel.handleAddToChart("sig_2", "MDB_2", 0xFFBA1A1AL, "A", isCan = false) + + val seriesList = chartRenderer.seriesListFlow.value + assertEquals(2, seriesList.size) + assertEquals(AxisPosition.LEFT_0, seriesList.find { it.signalId == "sig_1" }?.axisPosition) + assertEquals(AxisPosition.LEFT_1, seriesList.find { it.signalId == "sig_2" }?.axisPosition) + + viewModel.handleAddToChart("can_1", "CAN_1", 0xFF388E3CL, "km/h", isCan = true) + viewModel.handleAddToChart("can_2", "CAN_2", 0xFFF57C00L, "°C", isCan = true) + + val updated = chartRenderer.seriesListFlow.value + assertEquals(4, updated.size) + assertEquals(AxisPosition.LEFT_2, updated.find { it.signalId == "can_1" }?.axisPosition) + assertEquals(AxisPosition.LEFT_3, updated.find { it.signalId == "can_2" }?.axisPosition) + } + + @Test + @DisplayName("MDB 로드 후 A2L 프로젝트로 전환 시 이전 MDB 데이터 및 MDB 서브윈도우가 완전 초기화되어야 한다") + fun testProjectSwitchingMdbToA2lClearsMdbSignals() = runTest { + // 1. MDB 로드 + viewModel.openMdbFromFile(File("Sample.mdb")) + assertNotNull(viewModel.uiState.value.mdbDatabase) + assertEquals("Sample.mdb", viewModel.uiState.value.mdbFileName) + + // 2. A2L 전용 새 프로젝트 생성/설정 + val a2lProject = com.dct.app.domain.model.project.DpcProject( + projectFileName = "a2l_only.dpc", + a2lFileName = "ecu.a2l", + mdbFileName = "" + ) + viewModel.setCurrentProject(a2lProject) + + // 3. 검증: MDB 데이터베이스가 null로 초기화되고 MDB 서브윈도우가 닫혀야 함 + assertNull(viewModel.uiState.value.mdbDatabase) + assertNull(viewModel.uiState.value.mdbFileName) + assertEquals("a2l_only.dpc", viewModel.uiState.value.projectFileName) + } + + @Test + @DisplayName("autoSaveOnExit 호출 시 활성 프로젝트가 있으면 자동 저장이 성공해야 한다") + fun testAutoSaveOnExit() = runTest { + val mockContext = mockk(relaxed = true) + val testProject = com.dct.app.domain.model.project.DpcProject( + projectFileName = "AUTO_TEST", + vehicleCode = "GENESIS", + mdbFileName = "Engine.mdb" + ) + viewModel.setCurrentProject(testProject) + + val result = viewModel.autoSaveOnExit(mockContext) + assertTrue(result) + } + + @Test + @DisplayName("resetWorkspaceForNewProject 호출 시 모든 서브윈도우, 차트, 페이지, 텔레메트리, 신호트리가 완전히 초기화되어야 한다") + fun testResetWorkspaceForNewProjectClearsAllScreensAndStates() = runTest { + // 1. MDB 로드 및 서브윈도우/차트/페이지/계측 상태 생성 + viewModel.openMdbFromFile(File("Sample.mdb")) + mdiViewModel.addGraphWindow() + viewModel.addPage() + viewModel.handleAddToChart( + signalId = "sig_1_1", + signalName = "Ta_Motor_Speed", + colorLong = 0xFF0052CCL, + unit = "RPM" + ) + connectionState.value = DeviceConnectionState.READY + viewModel.startMeasure(30) + + // 사전 상태 확인 + assertTrue(mdiViewModel.uiState.value.windows.isNotEmpty()) + assertTrue(chartRenderer.seriesListFlow.value.isNotEmpty()) + assertTrue(viewModel.pages.value.size > 1) + assertTrue(viewModel.uiState.value.isMeasuring) + + // 2. 화면 및 상태 완전 초기화 호출 + viewModel.resetWorkspaceForNewProject() + + // 3. 완전 초기화 검증 + assertTrue(mdiViewModel.uiState.value.windows.isEmpty(), "MDI 서브윈도우가 모두 닫혀야 함") + assertNull(mdiViewModel.uiState.value.activeWindowId) + assertTrue(chartRenderer.seriesListFlow.value.isEmpty(), "차트 바인딩이 모두 해제되어야 함") + assertEquals(1, viewModel.pages.value.size, "페이지는 Page 1 단일 탭으로 리셋되어야 함") + assertEquals("page_1", viewModel.activePageId.value) + assertFalse(viewModel.uiState.value.isMeasuring, "계측이 중지되어야 함") + assertNull(viewModel.uiState.value.mdbDatabase) + assertNull(viewModel.uiState.value.mdbFileName) + assertNull(viewModel.uiState.value.projectFileName) + assertTrue(viewModel.canSignals.value.isEmpty()) + assertTrue(viewModel.a2lSignals.value.isEmpty()) + assertTrue(viewModel.liveTelemetryValues.value.isEmpty()) + assertTrue(treeViewModel.uiState.value.rootNodes.isEmpty(), "신호 트리 노드가 초기화되어야 함") + } + + @Test + @DisplayName("기존 프로젝트가 열린 상태에서 새 프로젝트 열기 시 화면이 초기화된 후 첫 열(16dp, 264dp)부터 정상 배치되어야 한다") + fun testOpeningNewProjectResetsWindowsFromFirstColumn() = runTest { + // 1. 기존 프로젝트 상태 (여러 윈도우가 우측으로 배치된 상태) + viewModel.openMdbFromFile(File("Sample.mdb")) + mdiViewModel.openOrFocusCategoryWindow(type = MdiWindowType.CAN_SIGNAL_LIST, title = "CAN Signal") + val oldWindowCount = mdiViewModel.uiState.value.windows.size + assertTrue(oldWindowCount >= 2) + + // 2. 새 프로젝트 로드 흐름 (resetWorkspaceForNewProject 후 새 MDB 로드) + viewModel.resetWorkspaceForNewProject() + viewModel.openMdbFromFile(File("Sample.mdb")) + + val newWindows = mdiViewModel.uiState.value.windows + assertEquals(2, newWindows.size) + val signalListWin = newWindows.find { it.type == MdiWindowType.SIGNAL_LIST } + val mdbWin = newWindows.find { it.type == MdiWindowType.MDB_SIGNAL_LIST } + + assertNotNull(signalListWin) + assertNotNull(mdbWin) + assertEquals(16f, signalListWin!!.offsetX, "Signal list는 캔버스 좌측 시작 위치(16dp)에 배치되어야 함") + assertEquals(264f, mdbWin!!.offsetX, "MDB Signal은 두 번째 열(264dp)에 배치되어야 함 (옆으로 밀리지 않음)") + } + + @Test + @DisplayName("전송 계층과 핸드셰이크 완료 전에는 연결 성공 상태를 표시하지 않아야 한다") + fun testCommunicationConnectionAndDisconnectionState() = runTest { + // 1. 초기 상태: 연결 없음 + assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType) + assertFalse(viewModel.uiState.value.isConnected) + assertNull(viewModel.uiState.value.connectedDeviceName) + + // 2. 실제 Bluetooth socket 없이 이름만 선택하면 연결 성공으로 오표시하지 않는다. + viewModel.connectBluetooth("DCT-CAN-BLE-01") + assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType) + assertFalse(viewModel.uiState.value.isConnected) + assertNull(viewModel.uiState.value.connectedDeviceName) + + // 3. 통신 해제 + viewModel.disconnectCommunication() + assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType) + assertFalse(viewModel.uiState.value.isConnected) + assertNull(viewModel.uiState.value.connectedDeviceName) + + // 4. Wi-Fi 연결 요청도 TCP 연결 및 4단계 handshake 전에는 false를 유지한다. + viewModel.connectWifi("DCT_WIFI_CAN_AP") + assertEquals(com.dct.app.presentation.main.CommunicationType.WIFI, viewModel.uiState.value.communicationType) + assertFalse(viewModel.uiState.value.isConnected) + assertEquals("DCT_WIFI_CAN_AP", viewModel.uiState.value.connectedDeviceName) + + // 5. 프로젝트 리셋은 미완료 핸드셰이크를 연결 성공으로 바꾸면 안 된다. + viewModel.resetWorkspaceForNewProject() + assertEquals(com.dct.app.presentation.main.CommunicationType.WIFI, viewModel.uiState.value.communicationType) + assertFalse(viewModel.uiState.value.isConnected) + assertEquals("DCT_WIFI_CAN_AP", viewModel.uiState.value.connectedDeviceName) + + // 6. 명시적 통신 해제 시 비로소 NONE으로 초기화 + viewModel.disconnectCommunication() + assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType) + assertFalse(viewModel.uiState.value.isConnected) + assertNull(viewModel.uiState.value.connectedDeviceName) + } + + @Test + @DisplayName("moveWindowToNextPage 및 moveWindowToPreviousPage 호출 시 현재 보고 있는 페이지는 유지되고 창 및 드랍된 신호 데이터가 온전히 보존되어야 한다") + fun testMoveWindowToNextAndPreviousPage() { + assertEquals(1, viewModel.pages.value.size) + assertEquals("page_1", viewModel.activePageId.value) + + // 1. Page 1에 그래프 창 추가 및 신호 데이터 드랍 + val g1 = viewModel.mdiViewModel.addGraphWindow() + viewModel.handleAddToChart("sig_1_1", "Ta_Motor_Speed", 0xFF0052CCL, "RPM", isCan = false, targetWindowId = g1) + assertEquals(1, chartRenderer.seriesListFlow.value.size) + + val g2 = viewModel.mdiViewModel.addGraphWindow() + assertEquals(2, viewModel.mdiViewModel.uiState.value.windows.size) + + // 2. g1 창을 다음 페이지(Page 2)로 이동 + viewModel.moveWindowToNextPage(g1) + + // 현재 보는 페이지는 여전히 page_1이어야 함 (사용자 화면 유지) + assertEquals("page_1", viewModel.activePageId.value) + // page_1 캔버스에는 g2 창만 남아 있어야 함 + assertEquals(1, viewModel.mdiViewModel.uiState.value.windows.size) + assertEquals(g2, viewModel.mdiViewModel.uiState.value.windows.first().id) + + // Page 2에는 g1 창과 드랍되었던 신호 데이터가 온전히 이동되어 보존되어 있어야 함 + assertEquals(2, viewModel.pages.value.size) + val page2 = viewModel.pages.value[1] + val movedWin = page2.windows.find { it.id == g1 } + assertNotNull(movedWin) + assertTrue(movedWin!!.config.boundSignals.any { it.signalId == "sig_1_1" }) + assertTrue(page2.boundSignals.any { it.signalId == "sig_1_1" }) + + // 3. Page 2로 전환 시 g1의 차트 신호가 복원되어 렌더링되어야 함 + viewModel.switchPage(page2.id) + assertEquals(page2.id, viewModel.activePageId.value) + assertTrue(viewModel.mdiViewModel.uiState.value.windows.any { it.id == g1 }) + assertEquals(1, chartRenderer.seriesListFlow.value.size) + assertEquals("Ta_Motor_Speed", chartRenderer.seriesListFlow.value[0].signalName) + + // 4. g1 창을 다시 Page 1으로 이전 + viewModel.moveWindowToPreviousPage(g1) + assertEquals(page2.id, viewModel.activePageId.value) + assertFalse(viewModel.mdiViewModel.uiState.value.windows.any { it.id == g1 }) + + // 5. Page 1으로 돌아왔을 때 g1 창과 신호 데이터가 온전히 복원됨 + viewModel.switchPage("page_1") + assertEquals("page_1", viewModel.activePageId.value) + assertTrue(viewModel.mdiViewModel.uiState.value.windows.any { it.id == g1 }) + assertEquals(1, chartRenderer.seriesListFlow.value.size) + assertEquals("Ta_Motor_Speed", chartRenderer.seriesListFlow.value[0].signalName) + } + + @Test + @DisplayName("탭(페이지)을 전환하더라도 차트 데이터 및 프로젝트 상태가 유지되며, 그래프 창 생성 번호는 탭에 무관하게 전역 순차 번호가 매겨져야 한다") + fun testProjectSharingAcrossTabsAndGlobalGraphNumbering() { + assertEquals(1, viewModel.pages.value.size) + assertEquals("page_1", viewModel.activePageId.value) + + // 1. Page 1에서 첫 번째 그래프 창 생성 -> GraphWindow 01 + val g1Id = viewModel.addGraphWindow() + val g1Win = viewModel.mdiViewModel.uiState.value.windows.find { it.id == g1Id } + assertNotNull(g1Win) + assertEquals("GraphWindow 01", g1Win!!.title) + + // 신호 바인딩 및 데이터 샘플 주입 + viewModel.handleAddToChart("sig_speed", "Motor_Speed", 0xFF0000L, "RPM", isCan = false, targetWindowId = g1Id) + chartRenderer.addSampleSec("sig_speed", 1.0, 1500.0) + assertEquals(1, chartRenderer.seriesListFlow.value.size) + + // 2. 새 탭 Page 2 생성 및 전환 + val page2Id = viewModel.addPage() + assertEquals(page2Id, viewModel.activePageId.value) + + // 탭이 바뀌어도 이전 탭에서 수집된 차트 신호 및 프로젝트 상태는 공유/보존되어야 함 + assertEquals(1, chartRenderer.seriesListFlow.value.size) + assertEquals("Motor_Speed", chartRenderer.seriesListFlow.value[0].signalName) + + // 3. Page 2에서 새 그래프 창 생성 -> 탭이 달라도 전역 순차 번호 GraphWindow 02가 부여되어야 함 + val g2Id = viewModel.addGraphWindow() + val g2Win = viewModel.mdiViewModel.uiState.value.windows.find { it.id == g2Id } + assertNotNull(g2Win) + assertEquals("GraphWindow 02", g2Win!!.title) + + // 4. 다시 Page 1으로 전환해도 데이터와 창 번호 일관성이 100% 유지됨 + viewModel.switchPage("page_1") + assertEquals("page_1", viewModel.activePageId.value) + assertEquals(1, chartRenderer.seriesListFlow.value.size) + + // 5. Page 1에서 다시 그래프 창을 추가하면 GraphWindow 03이 되어야 함 + val g3Id = viewModel.addGraphWindow() + val g3Win = viewModel.mdiViewModel.uiState.value.windows.find { it.id == g3Id } + assertNotNull(g3Win) + assertEquals("GraphWindow 03", g3Win!!.title) + } + + @Test + @DisplayName("복수의 그래프 윈도우(GraphWindow, MultiAxisGraph 등)에 신호를 드롭했을 때 각 창별로 독립적으로 신호 및 차트가 격리 관리되어야 한다") + fun testGraphWindowsHaveIndependentSeriesAndRenderers() { + // 1. 2개의 그래프 윈도우 생성 (Window 1, Window 2) + val win1Id = viewModel.addGraphWindow() + val win2Id = viewModel.addGraphWindow() + + val r1 = viewModel.getChartRenderer(win1Id) + val r2 = viewModel.getChartRenderer(win2Id) + + // 2. Window 1에만 신호 A 추가 + viewModel.handleAddToChart( + signalId = "sig_speed", + signalName = "Motor_Speed", + colorLong = 0xFF0000L, + unit = "RPM", + isCan = false, + targetWindowId = win1Id + ) + + // 3. Window 2에만 신호 B 추가 + viewModel.handleAddToChart( + signalId = "can_torque", + signalName = "Target_Torque", + colorLong = 0x00FF00L, + unit = "Nm", + isCan = true, + targetWindowId = win2Id + ) + + // 4. 검증: Window 1에는 sig_speed만 존재하고 Target_Torque는 없어야 함 + assertEquals(1, r1.seriesListFlow.value.size) + assertEquals("Motor_Speed", r1.seriesListFlow.value[0].signalName) + assertFalse(r1.seriesListFlow.value.any { it.signalId == "can_torque" }) + + // 5. 검증: Window 2에는 can_torque만 존재하고 Motor_Speed는 없어야 함 + assertEquals(1, r2.seriesListFlow.value.size) + assertEquals("Target_Torque", r2.seriesListFlow.value[0].signalName) + assertFalse(r2.seriesListFlow.value.any { it.signalId == "sig_speed" }) + + // 6. Window 1에서 신호 삭제 시 Window 2에는 영향이 전혀 없어야 함 + viewModel.unbindChartSignal("sig_speed", targetWindowId = win1Id) + assertEquals(0, r1.seriesListFlow.value.size) + assertEquals(1, r2.seriesListFlow.value.size) + assertEquals("Target_Torque", r2.seriesListFlow.value[0].signalName) + + // 7. Window 2 전체 삭제 시 정상 초기화 + viewModel.clearChartSignals(targetWindowId = win2Id) + assertEquals(0, r2.seriesListFlow.value.size) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt b/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt new file mode 100644 index 0000000..1cfd89c --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt @@ -0,0 +1,42 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DctResult +import com.dct.app.data.maintenance.* +import com.dct.app.domain.model.protocol.* +import com.dct.app.domain.repository.* +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class MaintenanceServiceTest { + @Test fun didReadChecksStatusAndEchoedAddress() = runTest { + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) + coEvery { repo.sendCommand(any()) } returns DctResult.Success(DctPacket.create(DctCommand.RES_REQ_DID_INFO, byteArrayOf(-15,-121,1,2,65,66))) + val service = MaintenanceService(repo) + assertArrayEquals(byteArrayOf(65,66), service.readDid(0xF187)) + assertTrue(runCatching { service.readDid(0xF191) }.isFailure) + coEvery { repo.sendCommand(any()) } returns DctResult.Success(DctPacket.create(DctCommand.RES_REQ_DID_INFO, byteArrayOf(-15,-121,2,0))) + assertTrue(runCatching { service.readDid(0xF187) }.isFailure) + } + @Test fun maintenanceNeverSendsWhileMeasuring() = runTest { + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.MEASURING) + assertTrue(runCatching { MaintenanceService(repo).deleteDatabase(0) }.isFailure) + coVerify(exactly = 0) { repo.sendCommand(any()) } + } + @Test fun databaseListValidatesIndexAndLength() = runTest { + val repo = mockk() + every { repo.connectionState } returns MutableStateFlow(DeviceConnectionState.READY) + coEvery { repo.sendCommand(any()) } coAnswers { + val request = firstArg() + DctResult.Success(DctPacket.create(DctCommand.RES_DB, + if (request.payload[0] == 1.toByte()) byteArrayOf(1,1,1) else byteArrayOf(2,1,0,2,65,66))) + } + assertEquals(listOf("AB"), MaintenanceService(repo).listDatabases()) + coEvery { repo.sendCommand(match { it.payload[0] == 2.toByte() }) } returns DctResult.Success(DctPacket.create(DctCommand.RES_DB, byteArrayOf(2,1,8,2,65,66))) + assertTrue(runCatching { MaintenanceService(repo).listDatabases() }.isFailure) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MdbRepositoryCrudTest.kt b/Android/app/src/test/java/com/dct/app/tests/MdbRepositoryCrudTest.kt new file mode 100644 index 0000000..eef1efc --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MdbRepositoryCrudTest.kt @@ -0,0 +1,253 @@ +package com.dct.app.tests + +import com.google.common.truth.Truth.assertThat +import com.dct.app.core.common.DctResult +import com.dct.app.core.common.DispatcherProvider +import com.dct.app.data.mdb.VirtualMdbGenerator +import com.dct.app.data.repository.MdbRepositoryImpl +import com.dct.app.domain.model.mdb.MapPoint +import com.dct.app.domain.usecase.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@DisplayName("M3 MdbRepository & UseCase CRUD 및 영속성 통합 검증") +class MdbRepositoryCrudTest { + + @TempDir + lateinit var tempDir: File + + private val testDispatcher = StandardTestDispatcher() + private val testDispatchers = object : DispatcherProvider { + override val main: CoroutineDispatcher = testDispatcher + override val mainImmediate: CoroutineDispatcher = testDispatcher + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val unconfined: CoroutineDispatcher = testDispatcher + } + + private lateinit var repository: MdbRepositoryImpl + + // UseCases + private lateinit var getMdbDatabaseUseCase: GetMdbDatabaseUseCase + private lateinit var updateConstantUseCase: UpdateConstantUseCase + private lateinit var updateCompensationMapUseCase: UpdateCompensationMapUseCase + private lateinit var saveMdbUseCase: SaveMdbUseCase + private lateinit var createStandardSampleMdbUseCase: CreateStandardSampleMdbUseCase + + @BeforeEach + fun setUp() { + repository = MdbRepositoryImpl(testDispatchers) + getMdbDatabaseUseCase = GetMdbDatabaseUseCase(repository) + updateConstantUseCase = UpdateConstantUseCase(repository) + updateCompensationMapUseCase = UpdateCompensationMapUseCase(repository) + saveMdbUseCase = SaveMdbUseCase(repository) + createStandardSampleMdbUseCase = CreateStandardSampleMdbUseCase(repository) + } + + @AfterEach + fun tearDown() { + repository.close() + } + + // ========================================================================= + // 1. File 및 InputStream 기반 로드 검증 + // ========================================================================= + + @Test + @DisplayName("표준 MDB 파일 로드 및 도메인 객체 트리 구성 검증") + fun testLoadMdbFromFile() = runTest(testDispatcher) { + val mdbFile = File(tempDir, "test_load.mdb") + VirtualMdbGenerator.createStandardDatabase(mdbFile).close() + + val result = getMdbDatabaseUseCase(mdbFile) + assertThat(result).isInstanceOf(DctResult.Success::class.java) + + val db = (result as DctResult.Success).data + assertThat(db.groups).hasSize(3) + assertThat(db.constants).hasSize(4) + assertThat(db.compensationMaps).hasSize(2) + + assertThat(repository.getCurrentDatabase()).isEqualTo(db) + } + + @Test + @DisplayName("InputStream으로부터 MDB 로드 및 도메인 객체 변환 검증") + fun testLoadMdbFromInputStream() = runTest(testDispatcher) { + val outStream = ByteArrayOutputStream() + VirtualMdbGenerator.createStandardDatabase(outStream) + val inStream = ByteArrayInputStream(outStream.toByteArray()) + + val result = getMdbDatabaseUseCase(inStream) + assertThat(result).isInstanceOf(DctResult.Success::class.java) + + val db = (result as DctResult.Success).data + assertThat(db.groups).hasSize(3) + assertThat(db.allSignals).hasSize(10) + } + + // ========================================================================= + // 2. ConLst 상수 CRUD 검증 + // ========================================================================= + + @Test + @DisplayName("ConLst 상수 값(Target_RPM 850 -> 920.0) 및 설명 수정 영속성 검증") + fun testUpdateConstantAndPersistence() = runTest(testDispatcher) { + val mdbFile = File(tempDir, "test_const.mdb") + VirtualMdbGenerator.createStandardDatabase(mdbFile).close() + + getMdbDatabaseUseCase(mdbFile) + + // 1. 상수 값 수정 + val updateRes = updateConstantUseCase("Target_RPM", 920.0, "변경된 목표 RPM") + assertThat(updateRes).isInstanceOf(DctResult.Success::class.java) + + val updatedDb = repository.getCurrentDatabase() + val constItem = updatedDb?.findConstant("Target_RPM") + assertThat(constItem?.data).isEqualTo(920.0) + assertThat(constItem?.description).isEqualTo("변경된 목표 RPM") + + // 2. 다른 상수 TCRmin 수정 + val tcrRes = updateConstantUseCase("TCRmin", 25.0) + assertThat(tcrRes).isInstanceOf(DctResult.Success::class.java) + assertThat(repository.getCurrentDatabase()?.findConstant("TCRmin")?.data).isEqualTo(25.0) + } + + // ========================================================================= + // 3. 1D 보정 맵 포인트 및 메타데이터 수정 검증 + // ========================================================================= + + @Test + @DisplayName("1D 보정 맵 포인트 일괄 수정 및 영속화 검증") + fun testUpdateCompensationMapPoints() = runTest(testDispatcher) { + val mdbFile = File(tempDir, "test_comp_points.mdb") + VirtualMdbGenerator.createStandardDatabase(mdbFile).close() + + getMdbDatabaseUseCase(mdbFile) + + val newPoints = listOf( + MapPoint(idx = 0, bkp = 600, data = 130, phy = 1.30), + MapPoint(idx = 1, bkp = 1200, data = 115, phy = 1.15), + MapPoint(idx = 2, bkp = 2200, data = 102, phy = 1.02), + MapPoint(idx = 3, bkp = 3200, data = 98, phy = 0.98), + MapPoint(idx = 4, bkp = 4200, data = 91, phy = 0.91) + ) + + val updateRes = updateCompensationMapUseCase("MAP_COOLANT_TEMP_COMP", newPoints) + assertThat(updateRes).isInstanceOf(DctResult.Success::class.java) + + val map = repository.getCurrentDatabase()?.findCompensationMap("MAP_COOLANT_TEMP_COMP") + assertThat(map).isNotNull() + assertThat(map?.points).hasSize(5) + assertThat(map?.points?.get(0)?.bkp).isEqualTo(600) + assertThat(map?.points?.get(0)?.data).isEqualTo(130) + assertThat(map?.points?.get(0)?.phy).isEqualTo(1.30) + } + + @Test + @DisplayName("1D 보정 맵 메타데이터(YMIN, YMAX, CVS_A, CVS_B, 설명) 수정 검증") + fun testUpdateCompensationMetadata() = runTest(testDispatcher) { + val mdbFile = File(tempDir, "test_comp_meta.mdb") + VirtualMdbGenerator.createStandardDatabase(mdbFile).close() + + getMdbDatabaseUseCase(mdbFile) + + val metaRes = updateCompensationMapUseCase.updateMetadata( + isTypeA = true, + yName = "MAP_COOLANT_TEMP_COMP", + min = -50.0, + max = 160.0, + cvsA = 1.5, + cvsB = -10.0, + description = "수정된 냉각수 보정 맵" + ) + assertThat(metaRes).isInstanceOf(DctResult.Success::class.java) + + val map = repository.getCurrentDatabase()?.findCompensationMap("MAP_COOLANT_TEMP_COMP") + assertThat(map).isNotNull() + assertThat(map?.yMin).isEqualTo(-50.0) + assertThat(map?.yMax).isEqualTo(160.0) + assertThat(map?.cvsA).isEqualTo(1.5) + assertThat(map?.cvsB).isEqualTo(-10.0) + assertThat(map?.description).isEqualTo("수정된 냉각수 보정 맵") + } + + // ========================================================================= + // 4. SaveMdb 및 OutputStream 내보내기 검증 + // ========================================================================= + + @Test + @DisplayName("수정된 MDB 데이터베이스 OutputStream 내보내기 및 재로드 검증") + fun testSaveMdbToOutputStream() = runTest(testDispatcher) { + val mdbFile = File(tempDir, "test_export_src.mdb") + VirtualMdbGenerator.createStandardDatabase(mdbFile).close() + + getMdbDatabaseUseCase(mdbFile) + updateConstantUseCase("Target_RPM", 999.0) + + val currentDb = repository.getCurrentDatabase()!! + val exportedStream = ByteArrayOutputStream() + + val saveRes = saveMdbUseCase(currentDb, exportedStream) + assertThat(saveRes).isInstanceOf(DctResult.Success::class.java) + + // 내보낸 바이트 스트림을 새 레포지토리에 로드하여 수정값 보존 확인 + val verifyRepo = MdbRepositoryImpl(testDispatchers) + val verifyRes = verifyRepo.loadMdb(ByteArrayInputStream(exportedStream.toByteArray())) + assertThat(verifyRes).isInstanceOf(DctResult.Success::class.java) + + val reloadedDb = (verifyRes as DctResult.Success).data + assertThat(reloadedDb.findConstant("Target_RPM")?.data).isEqualTo(999.0) + verifyRepo.close() + } + + // ========================================================================= + // 5. 표준 샘플 MDB 생성 유스케이스 검증 + // ========================================================================= + + @Test + @DisplayName("CreateStandardSampleMdbUseCase 파일 및 스트림 생성 검증") + fun testCreateStandardSampleDatabaseUseCase() = runTest(testDispatcher) { + // 1. File 대상 생성 + val targetFile = File(tempDir, "standard_created.mdb") + val fileRes = createStandardSampleMdbUseCase(targetFile) + assertThat(fileRes).isInstanceOf(DctResult.Success::class.java) + + val db = (fileRes as DctResult.Success).data + assertThat(db.groups).hasSize(3) + assertThat(targetFile.exists()).isTrue() + assertThat(targetFile.length()).isGreaterThan(0L) + + // 2. OutputStream 대상 생성 + val stream = ByteArrayOutputStream() + val streamRes = createStandardSampleMdbUseCase(stream) + assertThat(streamRes).isInstanceOf(DctResult.Success::class.java) + assertThat(stream.size()).isGreaterThan(0) + } + + // ========================================================================= + // 6. 에러 및 비정상 입력 방어 검증 + // ========================================================================= + + @Test + @DisplayName("존재하지 않는 파일 및 손상된 바이트 로드 시 DctResult.Failure 반환 검증") + fun testErrorHandlingNonExistentAndCorruptFiles() = runTest(testDispatcher) { + val nonExistentFile = File(tempDir, "ghost.mdb") + val ghostRes = getMdbDatabaseUseCase(nonExistentFile) + assertThat(ghostRes).isInstanceOf(DctResult.Failure::class.java) + + val corruptStream = ByteArrayInputStream(byteArrayOf(0x00, 0x01, 0x02, 0x03)) + val corruptRes = getMdbDatabaseUseCase(corruptStream) + assertThat(corruptRes).isInstanceOf(DctResult.Failure::class.java) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MdbSchemaParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/MdbSchemaParserTest.kt new file mode 100644 index 0000000..177dbec --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MdbSchemaParserTest.kt @@ -0,0 +1,246 @@ +package com.dct.app.tests + +import com.google.common.truth.Truth.assertThat +import com.healthmarketscience.jackcess.* +import com.dct.app.data.mdb.MdbSchemaParser +import com.dct.app.data.mdb.VirtualMdbGenerator +import com.dct.app.domain.model.mdb.CompensationMapType +import com.dct.app.domain.model.mdb.MdbAttrType +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +@DisplayName("M3 MdbSchemaParser: C++ LibDB.cpp 스키마 파싱 및 오프셋 계산 검증") +class MdbSchemaParserTest { + + @Test + @DisplayName("표준 가상 MDB 파싱: VarNif, VarLst, ConLst, Tb1Dm*, [YNAME] 전수 정합성 검증") + fun testParseStandardVirtualMdb(@TempDir tempDir: File) { + val targetFile = File(tempDir, "standard_test.mdb") + val db = VirtualMdbGenerator.createStandardDatabase(targetFile) + + val parsed = MdbSchemaParser.parseDatabase(db, targetFile.name) + db.close() + + assertThat(parsed.fileName).isEqualTo("standard_test.mdb") + + // 1. VarNif 그룹 검증 + assertThat(parsed.groups).hasSize(3) + val g1 = parsed.groups[0] + assertThat(g1.reclid).isEqualTo(1) + assertThat(g1.description).isEqualTo("Engine_Sensors") + assertThat(g1.startIndex).isEqualTo(0) + assertThat(g1.itemCount).isEqualTo(4) + + val g2 = parsed.groups[1] + assertThat(g2.reclid).isEqualTo(2) + assertThat(g2.description).isEqualTo("Transmission_Sensors") + assertThat(g2.itemCount).isEqualTo(3) + + val g3 = parsed.groups[2] + assertThat(g3.reclid).isEqualTo(3) + assertThat(g3.description).isEqualTo("Climate_Control") + assertThat(g3.itemCount).isEqualTo(3) + + // 2. CalculateVarLstItemIndex 오프셋 누적 검증 + // Group 1 신호 오프셋 검증: + // Sig0: EngineRPM (ATTR 4 -> size 4) => arrayIndex 0 + // Sig1: CoolantTemp (ATTR 2 -> size 2) => arrayIndex 4 + // Sig2: ThrottlePos (ATTR 0 -> size 1) => arrayIndex 6 + // Sig3: IntakePressure (ATTR 2 -> size 2) => arrayIndex 7 + assertThat(g1.signals).hasSize(4) + val rpmSig = g1.signals[0] + assertThat(rpmSig.name).isEqualTo("EngineRPM") + assertThat(rpmSig.attr).isEqualTo(MdbAttrType.ATTR_4_UINT32) + assertThat(rpmSig.itemSize).isEqualTo(4) + assertThat(rpmSig.arrayIndex).isEqualTo(0) + + val tempSig = g1.signals[1] + assertThat(tempSig.name).isEqualTo("CoolantTemp") + assertThat(tempSig.attr).isEqualTo(MdbAttrType.ATTR_2_UINT16) + assertThat(tempSig.itemSize).isEqualTo(2) + assertThat(tempSig.arrayIndex).isEqualTo(4) + + val throttleSig = g1.signals[2] + assertThat(throttleSig.name).isEqualTo("ThrottlePos") + assertThat(throttleSig.attr).isEqualTo(MdbAttrType.ATTR_0_UINT8) + assertThat(throttleSig.itemSize).isEqualTo(1) + assertThat(throttleSig.arrayIndex).isEqualTo(6) + + val pressureSig = g1.signals[3] + assertThat(pressureSig.name).isEqualTo("IntakePressure") + assertThat(pressureSig.attr).isEqualTo(MdbAttrType.ATTR_2_UINT16) + assertThat(pressureSig.itemSize).isEqualTo(2) + assertThat(pressureSig.arrayIndex).isEqualTo(7) + + // Group 2 신호 오프셋 검증: + // Sig0: VehicleSpeed (ATTR 2 -> size 2) => arrayIndex 0 + // Sig1: ClutchPressure (ATTR 2 -> size 2) => arrayIndex 2 + // Sig2: GearPosition (ATTR 1 -> size 1) => arrayIndex 4 + assertThat(g2.signals).hasSize(3) + assertThat(g2.signals[0].arrayIndex).isEqualTo(0) + assertThat(g2.signals[1].arrayIndex).isEqualTo(2) + assertThat(g2.signals[2].arrayIndex).isEqualTo(4) + + // 3. ConLst 상수 검증 + assertThat(parsed.constants).hasSize(4) + val tcr = parsed.findConstant("TCRmin") + assertThat(tcr).isNotNull() + assertThat(tcr?.data).isEqualTo(10.0) + assertThat(tcr?.addr).isEqualTo(0x1000L) + assertThat(tcr?.attr).isEqualTo(MdbAttrType.ATTR_2_UINT16) + + val targetRpm = parsed.findConstant("Target_RPM") + assertThat(targetRpm).isNotNull() + assertThat(targetRpm?.data).isEqualTo(850.0) + assertThat(targetRpm?.min).isEqualTo(500.0) + assertThat(targetRpm?.max).isEqualTo(6000.0) + + // 4. CompensationMap Type A & Type B 검증 + assertThat(parsed.compensationMaps).hasSize(2) + + val typeAMap = parsed.findCompensationMap("MAP_COOLANT_TEMP_COMP") + assertThat(typeAMap).isNotNull() + assertThat(typeAMap?.type).isEqualTo(CompensationMapType.TYPE_A) + assertThat(typeAMap?.isTypeA).isTrue() + assertThat(typeAMap?.xName).isEqualTo("ENGINE_SPEED") + assertThat(typeAMap?.points).hasSize(5) + assertThat(typeAMap?.points?.get(0)?.bkp).isEqualTo(500) + assertThat(typeAMap?.points?.get(0)?.phy).isEqualTo(1.20) + + val typeBMap = parsed.findCompensationMap("CURVE_BLOWER_PWM") + assertThat(typeBMap).isNotNull() + assertThat(typeBMap?.type).isEqualTo(CompensationMapType.TYPE_B) + assertThat(typeBMap?.isTypeB).isTrue() + assertThat(typeBMap?.xName).isEqualTo("CABIN_TEMP_DIFF") + assertThat(typeBMap?.points).hasSize(4) + assertThat(typeBMap?.points?.get(3)?.bkp).isEqualTo(15) + assertThat(typeBMap?.points?.get(3)?.phy).isEqualTo(100.0) + } + + @Test + @DisplayName("VarNif 부재 시 VarLst의 RECLID 기반 자동 그룹 생성 검증") + fun testFallbackWhenVarNifMissing(@TempDir tempDir: File) { + val targetFile = File(tempDir, "varnif_missing.mdb") + val db = DatabaseBuilder.create(Database.FileFormat.V2000, targetFile) + + // VarLst 테이블만 단독 생성 + val tbVarLst = TableBuilder("VarLst") + .addColumn(ColumnBuilder("RECLID", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("NAME", DataType.TEXT).setLength(255).toColumn()) + .addColumn(ColumnBuilder("ATTR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("CVS_A", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("CVS_B", DataType.DOUBLE).toColumn()) + .toTable(db) + + tbVarLst.addRow(10L, 0L, "SensorA", 2L, 1.0, 0.0) + tbVarLst.addRow(10L, 1L, "SensorB", 4L, 1.0, 0.0) + tbVarLst.addRow(20L, 0L, "SensorC", 0L, 1.0, 0.0) + db.flush() + + val parsed = MdbSchemaParser.parseDatabase(db, targetFile.name) + db.close() + + assertThat(parsed.groups).hasSize(2) + assertThat(parsed.groups[0].reclid).isEqualTo(10) + assertThat(parsed.groups[0].signals).hasSize(2) + assertThat(parsed.groups[0].signals[0].arrayIndex).isEqualTo(0) + assertThat(parsed.groups[0].signals[1].arrayIndex).isEqualTo(2) + + assertThat(parsed.groups[1].reclid).isEqualTo(20) + assertThat(parsed.groups[1].signals).hasSize(1) + assertThat(parsed.groups[1].signals[0].arrayIndex).isEqualTo(0) + } + + @Test + @DisplayName("포인트 테이블의 ID 컬럼명(IDX vs ID) 및 DESCRIPTION 컬럼 누락 방어 검증") + fun testPointTableColumnNameVariantsAndMissingDescription(@TempDir tempDir: File) { + val targetFile = File(tempDir, "variants.mdb") + val db = DatabaseBuilder.create(Database.FileFormat.V2000, targetFile) + + // Tb1DmPxDyLst (DESCRIPTION 컬럼 없음) + val tbPxDy = TableBuilder("Tb1DmPxDyLst") + .addColumn(ColumnBuilder("IDX", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("YNAME", DataType.TEXT).setLength(50).toColumn()) + .addColumn(ColumnBuilder("YADDR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("YATTR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("YMIN", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("YMAX", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("XNAME", DataType.TEXT).setLength(50).toColumn()) + .addColumn(ColumnBuilder("XADDR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("XATTR", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("CVS_A", DataType.DOUBLE).toColumn()) + .addColumn(ColumnBuilder("CVS_B", DataType.DOUBLE).toColumn()) + .toTable(db) + + tbPxDy.addRow(0L, "MAP_CUSTOM", 0x1000L, 2L, 0.0, 100.0, "MAP_X", 0x2000L, 2L, 1.0, 0.0) + + // MAP_CUSTOM 테이블 (첫 컬럼명이 ID) + val tbMap = TableBuilder("MAP_CUSTOM") + .addColumn(ColumnBuilder("ID", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("BKP", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("DATA", DataType.LONG).toColumn()) + .addColumn(ColumnBuilder("PHY", DataType.DOUBLE).toColumn()) + .toTable(db) + + tbMap.addRow(1L, 100L, 10L, 1.0) + tbMap.addRow(2L, 200L, 20L, 2.0) + db.flush() + + val parsed = MdbSchemaParser.parseDatabase(db, targetFile.name) + db.close() + + val map = parsed.findCompensationMap("MAP_CUSTOM") + assertThat(map).isNotNull() + assertThat(map?.points).hasSize(2) + assertThat(map?.points?.get(0)?.idx).isEqualTo(1) + assertThat(map?.points?.get(0)?.bkp).isEqualTo(100) + } + + @Test + fun testInspectRealMdbAndDbc() { + val mdbFile = File("C:/Users/COMPUTER1/.gemini/antigravity/brain/8a1b68ca-0b32-4fa8-acae-6f6d866f992b/test_sample.mdb") + if (mdbFile.exists()) { + val db = DatabaseBuilder.open(mdbFile) + println("=== MDB TABLES: ${db.tableNames} ===") + val varLst = db.getTable("VarLst") + if (varLst != null) { + println("=== VarLst COLUMNS: ${varLst.columns.map { it.name }} ===") + var count = 0 + for (row in varLst) { + if (count++ < 15) { + val name = row.get("NAME") + val color = row.get("COLOR") + val desc = row.get("DESCRIPTION") + val descrip = row.get("DESCRIP") + val reclid = row.get("RECLID") + val idx = row.get("IDX") + println("MDB VarLst Sig: name=$name, reclid=$reclid, idx=$idx, COLOR=$color (0x${java.lang.Long.toHexString((color as? Number)?.toLong() ?: 0L)}), desc=$desc, descrip=$descrip") + } + } + } + val varNif = db.getTable("VarNif") + if (varNif != null) { + println("=== VarNif COLUMNS: ${varNif.columns.map { it.name }} ===") + for (row in varNif) { + println("VarNif Row: RECLID=${row.get("RECLID")}, IDX=${row.get("IDX")}, DESCRIP=${row.get("DESCRIP")}") + } + } + db.close() + } + + val dbcFile = File("C:/Users/COMPUTER1/.gemini/antigravity/brain/8a1b68ca-0b32-4fa8-acae-6f6d866f992b/test_sample.dbc") + if (dbcFile.exists()) { + dbcFile.inputStream().use { stream -> + val signals = com.dct.app.data.parser.CanDbcParser.parseDbc(stream) + println("=== DBC SIGNALS COUNT: ${signals.size} ===") + for (sig in signals.take(15)) { + println("DBC Sig: name=${sig.name}, color=0x${java.lang.Long.toHexString(sig.color)}, desc='${sig.description}', msg=${sig.messageName}") + } + } + } + } +} + diff --git a/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt new file mode 100644 index 0000000..6f651f9 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt @@ -0,0 +1,231 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DefaultDispatcherProvider +import com.dct.app.presentation.mdi.MdiViewModel +import com.dct.app.presentation.mdi.MdiWindowConfig +import com.dct.app.presentation.mdi.MdiWindowType +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("M5 - MdiWorkspaceManager MDI 서브윈도우 관리자 단위 테스트") +class MdiWorkspaceManagerTest { + + private lateinit var dispatchers: DefaultDispatcherProvider + private lateinit var viewModel: MdiViewModel + + @BeforeEach + fun setUp() { + dispatchers = DefaultDispatcherProvider() + viewModel = MdiViewModel(dispatchers) + } + + @Test + @DisplayName("초기 상태에서 윈도우 목록은 비어 있고 activeWindowId는 null이어야 한다") + fun testInitialState() { + val state = viewModel.uiState.value + assertTrue(state.windows.isEmpty()) + assertNull(state.activeWindowId) + } + + @Test + @DisplayName("새 서브윈도우 탭을 추가하면 윈도우 목록이 증가하고 새로 추가된 탭이 활성화되어야 한다") + fun testAddWindow() { + val createdId = viewModel.addWindow( + type = MdiWindowType.CALIBRATION, + title = "제어기 캘리브레이션 1" + ) + + assertNotNull(createdId) + val state = viewModel.uiState.value + assertEquals(1, state.windows.size) + assertEquals(createdId, state.activeWindowId) + assertEquals("제어기 캘리브레이션 1", state.activeWindow?.title) + assertEquals(MdiWindowType.CALIBRATION, state.activeWindow?.type) + } + + @Test + @DisplayName("윈도우 탭을 닫으면 목록에서 제거되어야 한다") + fun testCloseWindow() { + val id1 = viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") + val id2 = viewModel.addWindow(type = MdiWindowType.CALIBRATION, title = "Calib") + assertNotNull(id1) + assertNotNull(id2) + assertEquals(2, viewModel.uiState.value.windows.size) + + viewModel.closeWindow(id2!!) + val state = viewModel.uiState.value + assertEquals(1, state.windows.size) + assertEquals(id1, state.activeWindowId) + } + + @Test + @DisplayName("닫기 불가(isClosable = false)로 설정된 윈도우는 닫히지 않아야 한다") + fun testUnclosableWindow() { + val id = viewModel.addWindow( + type = MdiWindowType.CHART, + title = "Primary Chart", + isClosable = false + ) + assertNotNull(id) + assertEquals(1, viewModel.uiState.value.windows.size) + + viewModel.closeWindow(id!!) + // isClosable=false이므로 닫히지 않음 + assertEquals(1, viewModel.uiState.value.windows.size) + } + + @Test + @DisplayName("서브윈도우 탭 전환(selectWindow)이 정상적으로 동작해야 한다") + fun testSelectWindow() { + val id1 = viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") + val id2 = viewModel.addWindow(type = MdiWindowType.DASHBOARD, title = "Dashboard") + + // id2가 활성화된 상태에서 id1으로 전환 + viewModel.selectWindow(id1!!) + assertEquals(id1, viewModel.uiState.value.activeWindowId) + + viewModel.selectWindow(id2!!) + assertEquals(id2, viewModel.uiState.value.activeWindowId) + } + + @Test + @DisplayName("서브윈도우 제목 변경(renameWindow)이 정상적으로 동작해야 한다") + fun testRenameWindow() { + val id = viewModel.addWindow(type = MdiWindowType.CHART, title = "Original") + assertNotNull(id) + + viewModel.renameWindow(id!!, "Renamed Chart") + val win = viewModel.uiState.value.windows.find { it.id == id } + assertEquals("Renamed Chart", win?.title) + } + + @Test + @DisplayName("윈도우 설정(config) 업데이트가 정상적으로 반영되어야 한다") + fun testUpdateWindowConfig() { + val id = viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") + assertNotNull(id) + + val newConfig = MdiWindowConfig(autoScroll = false, targetTableName = "TestTable") + viewModel.updateWindowConfig(id!!, newConfig) + + val win = viewModel.uiState.value.windows.find { it.id == id } + assertNotNull(win) + assertEquals(false, win?.config?.autoScroll) + assertEquals("TestTable", win?.config?.targetTableName) + } + + @Test + @DisplayName("최대 윈도우 개수(10개) 한도를 초과하여 윈도우를 추가할 수 없어야 한다") + fun testMaxWindowLimit() { + repeat(10) { i -> + viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart $i") + } + assertEquals(10, viewModel.uiState.value.windows.size) + + // 11번째 시도 - null 반환 + val extra = viewModel.addWindow(type = MdiWindowType.CHART, title = "Extra") + assertNull(extra) + assertEquals(10, viewModel.uiState.value.windows.size) + } + + @Test + @DisplayName("getOrCreateWindow는 기존 윈도우가 있으면 해당 윈도우를 포커스하고 없으면 생성해야 한다") + fun testGetOrCreateWindow() { + val id1 = viewModel.getOrCreateWindow(type = MdiWindowType.CHART, title = "Chart") + assertNotNull(id1) + assertEquals(1, viewModel.uiState.value.windows.size) + + // 같은 타입으로 다시 요청 → 기존 것 포커스 + val id2 = viewModel.getOrCreateWindow(type = MdiWindowType.CHART, title = "Chart") + assertEquals(id1, id2) + assertEquals(1, viewModel.uiState.value.windows.size) + + // 다른 타입 → 새로 생성 + val id3 = viewModel.getOrCreateWindow(type = MdiWindowType.CALIBRATION, title = "Calib") + assertNotNull(id3) + assertNotEquals(id1, id3) + assertEquals(2, viewModel.uiState.value.windows.size) + } + + @Test + @DisplayName("moveWindow 및 resizeWindow를 통한 실시간 드래그/리사이즈가 정확하게 갱신되어야 한다") + fun testMoveAndResizeWindow() { + val id = viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") + assertNotNull(id) + val initialWin = viewModel.uiState.value.windows.first() + + // 1. 이동 델타 적용 (+20, +30) + viewModel.moveWindow(id!!, 20f, 30f) + val movedWin = viewModel.uiState.value.windows.first() + assertEquals(initialWin.offsetX + 20f, movedWin.offsetX) + assertEquals(initialWin.offsetY + 30f, movedWin.offsetY) + + // 2. 크기 변경 델타 적용 (+50, +40) + viewModel.resizeWindow(id, 50f, 40f) + val resizedWin = viewModel.uiState.value.windows.first() + assertEquals(initialWin.width + 50f, resizedWin.width) + assertEquals(initialWin.height + 40f, resizedWin.height) + } + + @Test + @DisplayName("closeWindowsByTypes 호출 시 지정된 타입들의 윈도우가 일괄 닫혀야 한다") + fun testCloseWindowsByTypes() { + viewModel.addWindow(type = MdiWindowType.MDB_SIGNAL_LIST, title = "MDB Sig") + viewModel.addWindow(type = MdiWindowType.CONSTANT_SIGNAL_LIST, title = "Const Sig") + viewModel.addWindow(type = MdiWindowType.CHART, title = "Chart") + assertEquals(3, viewModel.uiState.value.windows.size) + + viewModel.closeWindowsByTypes(MdiWindowType.MDB_SIGNAL_LIST, MdiWindowType.CONSTANT_SIGNAL_LIST) + val remaining = viewModel.uiState.value.windows + assertEquals(1, remaining.size) + assertEquals(MdiWindowType.CHART, remaining.first().type) + } + + @Test + @DisplayName("addGraphWindow 호출 시 GraphWindow 01, GraphWindow 02, GraphWindow 03 등 순차 번호가 올바르게 부여되어야 한다") + fun testAddGraphWindowNumbered() { + val g1 = viewModel.addGraphWindow() + val g2 = viewModel.addGraphWindow() + val g3 = viewModel.addGraphWindow() + + val windows = viewModel.uiState.value.windows + assertEquals(3, windows.size) + assertEquals("GraphWindow 01", windows[0].title) + assertEquals("GraphWindow 02", windows[1].title) + assertEquals("GraphWindow 03", windows[2].title) + + // 중간의 GraphWindow 02를 닫고 다시 추가하면 미사용 번호(GraphWindow 02)가 재사용되어야 한다 + viewModel.closeWindow(g2) + assertEquals(2, viewModel.uiState.value.windows.size) + + val g2New = viewModel.addGraphWindow() + val updatedWindows = viewModel.uiState.value.windows + assertEquals(3, updatedWindows.size) + assertTrue(updatedWindows.any { it.title == "GraphWindow 02" }) + } + + @Test + @DisplayName("뷰포트 스크롤 좌표가 주어졌을 때 addGraphWindow와 openOrFocusCategoryWindow는 현재 화면 영역 내에 창을 배치해야 한다") + fun testWindowPlacementWithViewportCoordinates() { + val graphId = viewModel.addGraphWindow( + viewportScrollX = 500f, + viewportScrollY = 300f + ) + val graphWin = viewModel.uiState.value.windows.find { it.id == graphId } + assertNotNull(graphWin) + assertTrue(graphWin!!.offsetX >= 500f) + assertTrue(graphWin.offsetY >= 300f) + + val catId = viewModel.openOrFocusCategoryWindow( + type = MdiWindowType.CAN_SIGNAL_LIST, + viewportScrollX = 800f, + viewportScrollY = 400f + ) + val catWin = viewModel.uiState.value.windows.find { it.id == catId } + assertNotNull(catWin) + assertTrue(catWin!!.offsetX >= 800f) + assertTrue(catWin.offsetY >= 400f) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt new file mode 100644 index 0000000..8da09d1 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt @@ -0,0 +1,27 @@ +package com.dct.app.tests + +import com.dct.app.data.logger.MeasurementLogPaths +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.File + +class MeasurementLogPathsTest { + private val base = File(System.getProperty("java.io.tmpdir"), "DctWork").absoluteFile + @Test fun pcSlashDataIsResolvedUnderAndroidWorkFolder() { + assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("/Data", base.path, true, base)) + assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("/Data", base.path, false, base)) + assertEquals(File(base, "Data/logs"), MeasurementLogPaths.resolve("\\Data\\logs", base.path, true, base)) + } + @Test fun emptyBaseAndPathUseFallback() { + assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("", "", false, base)) + assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("/Data", "", true, base)) + } + @Test fun actualAbsoluteDirectoryIsNotRebasedAndRelativeCannotEscape() { + val absolute = File(base.parentFile, "ExplicitLogs") + assertEquals(absolute, MeasurementLogPaths.resolve(absolute.path, base.path, false, base)) + assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("../../outside", base.path, true, base)) + } + @Test fun uncPcDirectoryFallsBackToLocalData() { + assertEquals(File(base, "Data"), MeasurementLogPaths.resolve("\\\\PC\\Logs", "\\\\PC\\Project", false, base)) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt new file mode 100644 index 0000000..580ae06 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt @@ -0,0 +1,101 @@ +package com.dct.app.tests + +import com.dct.app.data.logger.CsvMeasurementLogger +import com.dct.app.data.logger.Mf4MeasurementLogger +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.io.RandomAccessFile + +@DisplayName("MeasurementLogger - CSV 및 ASAM MF4 실시간 계측 로거 단위 테스트") +class MeasurementLoggerTest { + + @Test + @DisplayName("CsvMeasurementLogger가 정상적으로 CSV 헤더 및 시계열 물리값 레코드를 생성해야 한다") + fun testCsvMeasurementLogger(@TempDir tempDir: File) { + val headers = listOf("EngineSpeed" to "RPM", "VehicleSpeed" to "km/h") + val logger = CsvMeasurementLogger(tempDir, headers) + + val createdFile = logger.start() + assertNotNull(createdFile) + assertTrue(createdFile!!.exists()) + + // 3개 샘플 기록 + logger.logSample(0.033, listOf(1500.0, 60.5)) + logger.logSample(0.066, listOf(1550.0, 61.2)) + logger.logSample(0.099, listOf(1600.0, 62.0)) + logger.stop() + + val lines = createdFile.readLines() + assertTrue(lines.isNotEmpty()) + assertEquals("Time(s),EngineSpeed [RPM],VehicleSpeed [km/h]", lines[0]) + assertTrue(lines[1].startsWith("0.0330,1500.0000,60.5000")) + assertTrue(lines[2].startsWith("0.0660,1550.0000,61.2000")) + assertTrue(lines[3].startsWith("0.0990,1600.0000,62.0000")) + } + + @Test + @DisplayName("Mf4MeasurementLogger가 ASAM MDF v4.10 매직 헤더 및 바이너리 블록을 정상 기록해야 한다") + fun testMf4MeasurementLogger(@TempDir tempDir: File) { + val headers = listOf("EngineSpeed" to "RPM", "CoolantTemp" to "'C") + val logger = Mf4MeasurementLogger(tempDir, headers) + + val createdFile = logger.start() + assertNotNull(createdFile) + assertTrue(createdFile!!.exists()) + + // 2개 샘플 기록 + logger.logSample(0.033, listOf(2000.0, 85.0)) + logger.logSample(0.066, listOf(2050.0, 85.5)) + logger.stop() + + // 바이너리 파일 크기 및 ASAM MDF 매직 넘버 검증 + assertTrue(createdFile.length() > 64) + RandomAccessFile(createdFile, "r").use { raf -> + val idMagic = ByteArray(8) + raf.readFully(idMagic) + assertEquals("MDF ", String(idMagic, Charsets.US_ASCII)) + + val versionMagic = ByteArray(8) + raf.readFully(versionMagic) + assertEquals("4.10 ", String(versionMagic, Charsets.US_ASCII)) + } + } + + @Test + @DisplayName("표준 파일명 규칙(프로젝트명_날짜_시간_소스타입.csv/mf4)으로 로거가 정상 생성되어야 한다") + fun testStandardNamingFormat(@TempDir tempDir: File) { + val headers = listOf("EngSpd" to "rpm", "Torque" to "Nm") + val customCsvName = "241213_SW_EU_TEST01_2026-07-02_08-39-15_dbc.csv" + val customMf4Name = "241213_SW_EU_TEST01_2026-07-02_08-39-15_mdb.mf4" + + val csvLogger = CsvMeasurementLogger(tempDir, headers, customCsvName) + val csvFile = csvLogger.start() + assertNotNull(csvFile) + assertEquals("241213_SW_EU_TEST01_2026-07-02_08-39-15_dbc.csv", csvFile!!.name) + csvLogger.stop() + + val mf4Logger = Mf4MeasurementLogger(tempDir, headers, customMf4Name) + val mf4File = mf4Logger.start() + assertNotNull(mf4File) + assertEquals("241213_SW_EU_TEST01_2026-07-02_08-39-15_mdb.mf4", mf4File!!.name) + mf4Logger.stop() + + // A2L / 통합 계측: _measurement + val a2lCsvName = "SP3_20260622_2026-07-02_13-40-46_measurement.csv" + val a2lMf4Name = "SP3_20260622_2026-07-02_13-40-17_measurement.mf4" + val a2lCsvLogger = CsvMeasurementLogger(tempDir, headers, a2lCsvName) + val a2lCsvFile = a2lCsvLogger.start() + assertNotNull(a2lCsvFile) + assertEquals("SP3_20260622_2026-07-02_13-40-46_measurement.csv", a2lCsvFile!!.name) + a2lCsvLogger.stop() + + val a2lMf4Logger = Mf4MeasurementLogger(tempDir, headers, a2lMf4Name) + val a2lMf4File = a2lMf4Logger.start() + assertNotNull(a2lMf4File) + assertEquals("SP3_20260622_2026-07-02_13-40-17_measurement.mf4", a2lMf4File!!.name) + a2lMf4Logger.stop() + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt new file mode 100644 index 0000000..4f45231 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt @@ -0,0 +1,174 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DctResult +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.logger.CsvMeasurementLogger +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.PacketRepository +import com.dct.app.presentation.main.MainViewModel +import io.mockk.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import java.nio.ByteBuffer +import java.nio.ByteOrder + +@Suppress("UNCHECKED_CAST") +class MeasurementRestartRegressionTest { + @TempDir lateinit var directory: Path + private lateinit var fixture: MainViewModelTest + private lateinit var vm: MainViewModel + private lateinit var repository: PacketRepository + private lateinit var packets: MutableSharedFlow + private lateinit var state: MutableStateFlow + private lateinit var loggers: MutableList + private lateinit var socket: WifiSocketManager + private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } + + @BeforeEach fun setUp() { + fixture = MainViewModelTest().also { it.setUp() } + vm = field(fixture, "viewModel").get(fixture) as MainViewModel + repository = field(fixture, "packetRepository").get(fixture) as PacketRepository + packets = repository.packetStream as MutableSharedFlow + state = field(fixture, "connectionState").get(fixture) as MutableStateFlow + loggers = field(vm, "activeCsvLoggers").get(vm) as MutableList + socket = mockk(relaxed = true) + field(vm, "wifiSocketManager").set(vm, socket) + vm.setCurrentProject(DpcProject(measuredFilesFolder = directory.toString(), writeCsv = false, writeMf4 = false)) + state.value = DeviceConnectionState.READY + } + + @AfterEach fun cleanUp() { + vm.scope.cancel() + fixture.tearDown() + } + + @Test fun clearingLoggerListDuringPacketProcessingDoesNotKillSubscription() { + vm.startMeasure() + val subscribers = packets.subscriptionCount.value + val headers = object : AbstractList>() { + override val size = 1 + override fun get(index: Int): Pair { + loggers.clear() // Force STOP's list mutation between iterator steps. + return "test" to "" + } + } + loggers.add(CsvMeasurementLogger(directory.toFile(), headers)) + loggers.add(CsvMeasurementLogger(directory.toFile(), emptyList())) + val before = vm.uiState.value.packetCount + packets.tryEmit(DctPacket(0x7F)) + packets.tryEmit(DctPacket(0x7F)) + assertEquals(subscribers, packets.subscriptionCount.value) + assertEquals(before + 2, vm.uiState.value.packetCount) + } + + @Test fun packetProcessingExceptionDoesNotPreventStopStartOrReconnectReceiving() { + vm.chartRenderer.bindSignal("s", "Incar_Snr", com.dct.app.domain.model.chart.AxisPosition.LEFT_0, 0xFF0000L) + fun telemetry(value: Float) = DctPacket(0x02, ByteArray(18).also { + val buffer = ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) + buffer.position(2) + buffer.putFloat(value).putFloat(2f).putFloat(3f).putFloat(4f) + }) + vm.startMeasure() + val subscribers = packets.subscriptionCount.value + val badHeaders = object : AbstractList>() { + override val size = 1 + override fun get(index: Int): Pair { + loggers.clear() + throw IllegalStateException("injected packet failure") + } + } + loggers.add(CsvMeasurementLogger(directory.toFile(), badHeaders)) + packets.tryEmit(telemetry(10f)) + assertTrue(vm.uiState.value.statusMessage!!.contains("다음 패킷 수신 계속")) + assertEquals(subscribers, packets.subscriptionCount.value) + val before = vm.uiState.value.packetCount + vm.stopMeasure() + vm.startMeasure() + packets.tryEmit(telemetry(20f)) + assertEquals(before + 1, vm.uiState.value.packetCount) + assertEquals(20.0, vm.liveTelemetryValues.value["Incar_Snr"]) + state.value = DeviceConnectionState.ERROR + state.value = DeviceConnectionState.DISCONNECTED + state.value = DeviceConnectionState.READY + vm.syncDeviceConfiguration() + vm.startMeasure() + packets.tryEmit(telemetry(30f)) + assertTrue(vm.uiState.value.isMeasuring) + assertEquals(before + 2, vm.uiState.value.packetCount) + assertEquals(30.0, vm.liveTelemetryValues.value["Incar_Snr"]) + val buffer = vm.chartRenderer.seriesListFlow.value.single().buffer + assertEquals(30.0, buffer.getValueAt(buffer.size - 1)) + } + + @Test fun startWaitsForStopCompletionAndDuplicateStartIsNotSent() { + vm.startMeasure() + val stopAck = CompletableDeferred>() + coEvery { repository.stopStreaming() } coAnswers { stopAck.await() } + vm.stopMeasure() + vm.startMeasure() + vm.startMeasure() + coVerify(exactly = 1) { repository.startStreaming(any()) } + stopAck.complete(DctResult.Success(Unit)) + coVerify(exactly = 2) { repository.startStreaming(any()) } + assertTrue(vm.uiState.value.isMeasuring) + } + + @Test fun pendingConfigurationMustFinishBeforeStartAndFailureBlocksStart() { + val configAck = CompletableDeferred>() + coEvery { repository.configureMeasurement(any()) } coAnswers { configAck.await() } + vm.syncDeviceConfiguration() + vm.startMeasure() + coVerify(exactly = 0) { repository.startStreaming(any()) } + configAck.complete(DctResult.Success(Unit)) + coVerify(exactly = 1) { repository.startStreaming(any()) } + vm.stopMeasure() + coEvery { repository.configureMeasurement(any()) } returns DctResult.Failure(com.dct.app.core.error.DctError.Unknown("NACK")) + vm.syncDeviceConfiguration() + vm.startMeasure() + coVerify(exactly = 1) { repository.startStreaming(any()) } + assertFalse(vm.uiState.value.isMeasuring) + } + + @Test fun disconnectInvalidatesDelayedStartAck() { + val startAck = CompletableDeferred>() + coEvery { repository.startStreaming(any()) } coAnswers { startAck.await() } + vm.startMeasure() + vm.disconnectCommunication() + startAck.complete(DctResult.Success(Unit)) + assertFalse(vm.uiState.value.isMeasuring) + coVerify(exactly = 1) { repository.disconnect() } + assertFalse(vm.uiState.value.isConnected) + } + + @Test fun reconnectWaitsForDisconnectAndOldConnectionCallbackIsIgnored() { + val callbacks = mutableListOf<(Boolean, String) -> Unit>() + every { socket.startDualMode(any(), any(), any(), capture(callbacks), any(), any()) } just Runs + coEvery { repository.connect(any()) } returns DctResult.Success(Unit) + vm.connectWifi("first", "127.0.0.1") + assertEquals(1, callbacks.size) + val disconnectAck = CompletableDeferred>() + coEvery { repository.disconnect() } coAnswers { disconnectAck.await() } + vm.disconnectCommunication() + vm.connectWifi("second", "127.0.0.1") + assertEquals(1, callbacks.size) + callbacks[0](false, "stale") + disconnectAck.complete(DctResult.Success(Unit)) + assertEquals(2, callbacks.size) + coVerify(exactly = 0) { repository.connect("stale") } + callbacks[1](false, "current") + coVerify(exactly = 1) { repository.connect("current") } + vm.startMeasure() + assertTrue(vm.uiState.value.isMeasuring) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt b/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt new file mode 100644 index 0000000..5cdc07b --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt @@ -0,0 +1,127 @@ +package com.dct.app.tests + +import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer +import com.dct.app.domain.model.chart.DownsamplingAlgorithms +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import kotlin.math.sin +import kotlin.system.measureTimeMillis + +@DisplayName("MinMaxDownsampler 픽셀 컬럼 다운샘플링 단위 테스트") +class MinMaxDownsamplerTest { + + @Test + @DisplayName("10,000개 데이터 포인트를 500픽셀로 압축 시 80% 이상의 축소율을 달성해야 한다") + fun testDataCompressionRatio() { + val totalPoints = 10_000 + val buffer = TimeSeriesRingBuffer(totalPoints) + for (i in 0 until totalPoints) { + val t = i * 0.001 + val v = sin(t * 10.0) * 100.0 + buffer.addSample(t, v) + } + + val screenWidthPx = 500 + val downsampled = DownsamplingAlgorithms.minMaxDownsample( + buffer = buffer, + firstIdx = 0, + lastIdx = totalPoints - 1, + tMin = 0.0, + tMax = 10.0, + screenWidthPx = screenWidthPx + ) + + // 화면 너비 500픽셀 기준 최대 2000개 이하로 압축 + assertTrue(downsampled.size <= screenWidthPx * 4, "압축된 포인트 개수가 4W 이하여야 함: ${downsampled.size}") + val reductionRatio = 1.0 - (downsampled.size.toDouble() / totalPoints.toDouble()) + assertTrue(reductionRatio >= 0.75, "압축률이 75% 이상이어야 합니다 (실제: ${reductionRatio * 100}%)") + } + + @Test + @DisplayName("중간에 발생한 급격한 임펄스 스파이크 Peak 및 Valley 극값이 100% 보존되어야 한다") + fun testImpulsePeakAndValleyPreservation() { + val totalPoints = 5_000 + val buffer = TimeSeriesRingBuffer(totalPoints) + + val spikeIndex = 2500 + val valleyIndex = 3500 + + for (i in 0 until totalPoints) { + val t = i * 0.01 + var v = 10.0 + if (i == spikeIndex) { + v = 9999.0 // 극단적 피크 + } else if (i == valleyIndex) { + v = -9999.0 // 극단적 밸리 + } + buffer.addSample(t, v) + } + + val downsampled = DownsamplingAlgorithms.minMaxDownsample( + buffer = buffer, + firstIdx = 0, + lastIdx = totalPoints - 1, + tMin = 0.0, + tMax = 50.0, + screenWidthPx = 300 + ) + + val values = downsampled.map { it.second } + val maxVal = values.maxOrNull() ?: Double.MIN_VALUE + val minVal = values.minOrNull() ?: Double.MAX_VALUE + + assertEquals(9999.0, maxVal, 1e-6, "최대 피크가 완벽히 보존되어야 합니다") + assertEquals(-9999.0, minVal, 1e-6, "최소 밸리가 완벽히 보존되어야 합니다") + } + + @Test + @DisplayName("계단형 PWM 디지털 신호의 On/Off 엣지가 손실 없이 보존되어야 한다") + fun testPwmSignalEdgePreservation() { + val totalPoints = 2_000 + val buffer = TimeSeriesRingBuffer(totalPoints) + + // 100ms마다 0과 1이 교차하는 PWM 신호 + for (i in 0 until totalPoints) { + val t = i * 0.01 + val v = if ((i / 10) % 2 == 0) 1.0 else 0.0 + buffer.addSample(t, v) + } + + val downsampled = DownsamplingAlgorithms.minMaxDownsample( + buffer = buffer, + firstIdx = 0, + lastIdx = totalPoints - 1, + tMin = 0.0, + tMax = 20.0, + screenWidthPx = 200 + ) + + val values = downsampled.map { it.second } + assertTrue(values.contains(0.0), "PWM Low 레벨이 포함되어야 합니다") + assertTrue(values.contains(1.0), "PWM High 레벨이 포함되어야 합니다") + } + + @Test + @DisplayName("10,000개 포인트 다운샘플링 수행 시간이 10ms 이내여야 한다") + fun testPerformanceBenchmark() { + val totalPoints = 10_000 + val buffer = TimeSeriesRingBuffer(totalPoints) + for (i in 0 until totalPoints) { + buffer.addSample(i * 0.001, (i % 50).toDouble()) + } + + // 워밍업 + DownsamplingAlgorithms.minMaxDownsample(buffer, 0, totalPoints - 1, 0.0, 10.0, 1000) + + val elapsed = measureTimeMillis { + for (iter in 0 until 10) { + DownsamplingAlgorithms.minMaxDownsample(buffer, 0, totalPoints - 1, 0.0, 10.0, 1000) + } + } + + val avgTime = elapsed / 10.0 + assertTrue(avgTime < 10.0, "평균 다운샘플링 소요 시간이 10ms 이내여야 합니다 (실제: ${avgTime}ms)") + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt b/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt new file mode 100644 index 0000000..4f0bee1 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt @@ -0,0 +1,189 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.chart.ChartAxisConfig +import com.dct.app.domain.model.chart.MultiAxisCoordinateTransformer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("MultiAxisCoordinateTransformer 단일축 및 다중축 좌표 변환 단위 테스트 (PC 레거시 1:1 검증)") +class MultiAxisCoordinateTransformTest { + + @Test + @DisplayName("시간에서 스크린 X 픽셀로의 변환 및 역변환 Round-trip 오차가 0.001% 미만이어야 한다") + fun testTimeCoordinateRoundTrip() { + val tMin = 10.0 + val tMax = 30.0 + val plotLeft = 100f + val plotWidth = 800f + + val testTimes = listOf(10.0, 15.0, 20.0, 25.5, 30.0) + + for (t in testTimes) { + val screenX = MultiAxisCoordinateTransformer.timeToScreenX( + timeSec = t, + tMinSec = tMin, + tMaxSec = tMax, + plotLeft = plotLeft, + plotWidth = plotWidth + ) + + if (t == 10.0) assertEquals(100f, screenX, 1e-4f) + if (t == 30.0) assertEquals(900f, screenX, 1e-4f) + if (t == 20.0) assertEquals(500f, screenX, 1e-4f) + + val recoveredTime = MultiAxisCoordinateTransformer.screenXToTime( + screenX = screenX, + tMinSec = tMin, + tMaxSec = tMax, + plotLeft = plotLeft, + plotWidth = plotWidth + ) + + val error = kotlin.math.abs(recoveredTime - t) + assertTrue(error < 1e-5, "Round-trip 시간 오차가 1e-5 미만이어야 함 (오차: $error)") + } + } + + @Test + @DisplayName("서로 다른 4개 Y축 범위에 대해 물리값과 Y픽셀 간의 정확한 독립 변환이 이루어져야 한다") + fun testMultiAxisValueToScreenY() { + val plotTop = 50f + val plotHeight = 400f + + val rpmAxis = ChartAxisConfig( + position = AxisPosition.LEFT_0, + minVal = 0.0, + maxVal = 8000.0 + ) + val y0 = MultiAxisCoordinateTransformer.valueToScreenY(0.0, rpmAxis.minVal, rpmAxis.maxVal, plotTop, plotHeight) + assertEquals(450f, y0, 1e-4f) + + val yMax = MultiAxisCoordinateTransformer.valueToScreenY(8000.0, rpmAxis.minVal, rpmAxis.maxVal, plotTop, plotHeight) + assertEquals(50f, yMax, 1e-4f) + + val yMid = MultiAxisCoordinateTransformer.valueToScreenY(4000.0, rpmAxis.minVal, rpmAxis.maxVal, plotTop, plotHeight) + assertEquals(250f, yMid, 1e-4f) + + val tempAxis = ChartAxisConfig( + position = AxisPosition.LEFT_1, + minVal = -40.0, + maxVal = 120.0 + ) + assertEquals(450f, MultiAxisCoordinateTransformer.valueToScreenY(-40.0, tempAxis.minVal, tempAxis.maxVal, plotTop, plotHeight), 1e-4f) + assertEquals(50f, MultiAxisCoordinateTransformer.valueToScreenY(120.0, tempAxis.minVal, tempAxis.maxVal, plotTop, plotHeight), 1e-4f) + assertEquals(250f, MultiAxisCoordinateTransformer.valueToScreenY(40.0, tempAxis.minVal, tempAxis.maxVal, plotTop, plotHeight), 1e-4f) + + val voltAxis = ChartAxisConfig( + position = AxisPosition.LEFT_2, + minVal = 10.0, + maxVal = 15.0 + ) + assertEquals(450f, MultiAxisCoordinateTransformer.valueToScreenY(10.0, voltAxis.minVal, voltAxis.maxVal, plotTop, plotHeight), 1e-4f) + assertEquals(50f, MultiAxisCoordinateTransformer.valueToScreenY(15.0, voltAxis.minVal, voltAxis.maxVal, plotTop, plotHeight), 1e-4f) + assertEquals(250f, MultiAxisCoordinateTransformer.valueToScreenY(12.5, voltAxis.minVal, voltAxis.maxVal, plotTop, plotHeight), 1e-4f) + + val throttleAxis = ChartAxisConfig( + position = AxisPosition.LEFT_3, + minVal = 0.0, + maxVal = 100.0 + ) + assertEquals(450f, MultiAxisCoordinateTransformer.valueToScreenY(0.0, throttleAxis.minVal, throttleAxis.maxVal, plotTop, plotHeight), 1e-4f) + assertEquals(50f, MultiAxisCoordinateTransformer.valueToScreenY(100.0, throttleAxis.minVal, throttleAxis.maxVal, plotTop, plotHeight), 1e-4f) + assertEquals(250f, MultiAxisCoordinateTransformer.valueToScreenY(50.0, throttleAxis.minVal, throttleAxis.maxVal, plotTop, plotHeight), 1e-4f) + + val recoveredRpm = MultiAxisCoordinateTransformer.screenYToValue(250f, rpmAxis.minVal, rpmAxis.maxVal, plotTop, plotHeight) + assertEquals(4000.0, recoveredRpm, 1e-4) + } + + @Test + @DisplayName("PC CChartStandardAxis 알고리즘에 따라 [-50 ~ 100] 범위는 10단위 16개 눈금이 정확히 생성되어야 한다") + fun testGenerateStandardTicksLegacyPC() { + val ticks = MultiAxisCoordinateTransformer.generateStandardTicks( + minVal = -50.0, + maxVal = 100.0, + heightPx = 300f, + pixelSpace = 20 + ) + + val expected = listOf(-50.0, -40.0, -30.0, -20.0, -10.0, 0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0) + assertEquals(expected.size, ticks.size, "눈금 개수는 16개여야 함") + for (i in expected.indices) { + assertEquals(expected[i], ticks[i], 1e-5, "인덱스 $i 눈금 불일치") + } + } + + @Test + @DisplayName("PC CChartDateTimeAxis 알고리즘에 따라 시간 타임스탬프가 정수 스냅 단위로 정렬 생성되어야 한다") + fun testGenerateTimeTicksLegacyPC() { + // 30초 윈도우 (56850.0 ~ 56880.0) + val timeTicks = MultiAxisCoordinateTransformer.generateTimeTicks( + tMinSec = 56850.0, + tMaxSec = 56880.0, + widthPx = 500f, + pixelSpace = 100 + ) + + assertTrue(timeTicks.isNotEmpty()) + for (t in timeTicks) { + // 모든 틱이 5초 또는 10초, 30초 등 정수 초 단위로 스냅 정렬 + assertEquals(0.0, t % 1.0, 1e-5, "틱은 정수 초 단위여야 함") + } + } + + @Test + @DisplayName("현재 그래프 여백: 단일축 58px, 다중축 12px + 축 너비 합계") + fun testLeftMarginCalculations() { + val widthPerAxis = 42f + + assertEquals(58f, MultiAxisCoordinateTransformer.calculateLeftMargin(1, isMultiAxis = false, axisWidthPx = widthPerAxis), 1e-4f) + assertEquals(58f, MultiAxisCoordinateTransformer.calculateLeftMargin(3, isMultiAxis = false, axisWidthPx = widthPerAxis), 1e-4f) + + assertEquals(58f, MultiAxisCoordinateTransformer.calculateLeftMargin(1, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) + assertEquals(96f, MultiAxisCoordinateTransformer.calculateLeftMargin(2, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) + assertEquals(138f, MultiAxisCoordinateTransformer.calculateLeftMargin(3, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) + assertEquals(180f, MultiAxisCoordinateTransformer.calculateLeftMargin(4, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) + assertEquals(180f, MultiAxisCoordinateTransformer.calculateLeftMargin(5, isMultiAxis = true, axisWidthPx = widthPerAxis), 1e-4f) + } + + @Test + @DisplayName("일반 그래프는 우측 외부 범례, 다중축 그래프는 하단 외부 범례 여백을 정확히 계산해야 한다") + fun testPlotBoundsByGraphType() { + val canvasWidth = 1000f + val canvasHeight = 600f + + // 1. 일반 그래프 (단일축, 우측 외부 범례 120px) + val singleBounds = MultiAxisCoordinateTransformer.calculatePlotBounds( + canvasWidth = canvasWidth, + canvasHeight = canvasHeight, + leftAxisCount = 1, + isMultiAxis = false, + hasLegend = true, + legendWidthPx = 110f + ) + assertEquals(58f, singleBounds.left, 1e-4f) + // rightMargin = (110 + 28).coerceIn(110..240) = 138f -> right = 1000 - 138 = 862f + assertEquals(862f, singleBounds.right, 1e-4f) + assertEquals(12f, singleBounds.top, 1e-4f) + // bottomMargin = 24f -> bottom = 600 - 24 = 576f + assertEquals(576f, singleBounds.bottom, 1e-4f) + + // 2. 다중축 그래프 (다중축 2개, 하단 외부 범례 62px) + val multiBounds = MultiAxisCoordinateTransformer.calculatePlotBounds( + canvasWidth = canvasWidth, + canvasHeight = canvasHeight, + leftAxisCount = 2, + isMultiAxis = true, + hasLegend = true, + legendPosition = 2, + axisWidthPx = 42f + ) + assertEquals(96f, multiBounds.left, 1e-4f) + assertEquals(984f, multiBounds.right, 1e-4f) + assertEquals(12f, multiBounds.top, 1e-4f) + // bottomMargin = 24 + 32 + 4 = 60f + assertEquals(540f, multiBounds.bottom, 1e-4f) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketFramingAndCodecTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketFramingAndCodecTest.kt new file mode 100644 index 0000000..9ec7235 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/PacketFramingAndCodecTest.kt @@ -0,0 +1,123 @@ +package com.dct.app.tests + +import com.google.common.truth.Truth.assertThat +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.domain.model.protocol.DctCommand +import com.dct.app.domain.model.protocol.DctPacket +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +@DisplayName("PacketFramingAndCodec (DctPacketCodec) 인코딩/디코딩 단위 테스트") +class PacketFramingAndCodecTest { + + private lateinit var codec: DctPacketCodec + + @BeforeEach + fun setUp() { + codec = DctPacketCodec() + } + + @Test + @DisplayName("C++ 호환 ENABLE 커맨드 패킷 인코딩 구조 및 바이트 정합성 검증") + fun testEncodeEnableCommand() { + // ENABLE 커맨드 (0x00), Payload 0바이트 -> packLen = 1 + val packet = DctPacket.create(DctCommand.CMD_ENABLE) + val encoded = codec.encode(packet) + + // 기대 크기: packLen(1) + 5 = 6 Bytes + assertThat(encoded.size).isEqualTo(6) + assertThat(encoded[0]).isEqualTo(0xA1.toByte()) // Header + assertThat(encoded[1]).isEqualTo(0x00.toByte()) // LenH + assertThat(encoded[2]).isEqualTo(0x01.toByte()) // LenL + assertThat(encoded[3]).isEqualTo(0x00.toByte()) // CmdID (CMD_ENABLE) + + // Checksum: (0xA1 + 0x00 + 0x01 + 0x00 = 0xA2) ^ 0xFF = 0x5D + assertThat(encoded[4]).isEqualTo(0x5D.toByte()) + assertThat(encoded[5]).isEqualTo(0x7E.toByte()) // Footer + + // 디코딩 검증 + val decoded = codec.decode(encoded) + assertThat(decoded).isNotNull() + assertThat(decoded?.command).isEqualTo(DctCommand.CMD_ENABLE.code) + assertThat(decoded?.payload).isEmpty() + } + + @ParameterizedTest + @EnumSource(DctCommand::class) + @DisplayName("모든 DctCommand에 대한 인코딩 및 디코딩 왕복(Round-trip) 무결성 검증") + fun testAllCommandsRoundTrip(command: DctCommand) { + val dummyPayload = byteArrayOf(0x01, 0x02, 0x03, 0x04) + val originalPacket = DctPacket.create(command, dummyPayload, sequence = 42) + + val frame = codec.encode(originalPacket) + assertThat(codec.isValidFrame(frame)).isTrue() + + val decoded = codec.decode(frame, sequence = 42) + assertThat(decoded).isNotNull() + assertThat(decoded?.command).isEqualTo(command.code) + assertThat(decoded?.payload).isEqualTo(dummyPayload) + assertThat(decoded?.sequence).isEqualTo(42) + } + + @Test + @DisplayName("Big-Endian 길이 필드 직렬화 검증 (대용량 1000바이트 페이로드)") + fun testLargePayloadBigEndianLengthEncoding() { + val largePayload = ByteArray(1000) { (it and 0xFF).toByte() } + val packet = DctPacket(command = DctCommand.SEND_CAN_FRAME.code, payload = largePayload) + + val encoded = codec.encode(packet) + val packLen = 1000 + 1 // 1001 = 0x03E9 + assertThat(packLen).isEqualTo(1001) + + assertThat(encoded.size).isEqualTo(1001 + 5) + assertThat(encoded[0]).isEqualTo(0xA1.toByte()) + assertThat(encoded[1]).isEqualTo(0x03.toByte()) // LenH + assertThat(encoded[2]).isEqualTo(0xE9.toByte()) // LenL + assertThat(encoded[3]).isEqualTo(DctCommand.SEND_CAN_FRAME.code) + assertThat(encoded[encoded.size - 1]).isEqualTo(0x7E.toByte()) + + val decoded = codec.decode(encoded) + assertThat(decoded).isNotNull() + assertThat(decoded?.payload?.size).isEqualTo(1000) + assertThat(decoded?.payload).isEqualTo(largePayload) + } + + @Test + @DisplayName("손상된 프레임은 decode 실패 및 isValidFrame false 반환") + fun testCorruptedFrames() { + val validPacket = DctPacket.create(DctCommand.CMD_VERSION, "V1.0".toByteArray()) + val validFrame = codec.encode(validPacket) + + // 1. 헤더 바이트 손상 + val badHeader = validFrame.copyOf() + badHeader[0] = 0x00 + assertThat(codec.decode(badHeader)).isNull() + assertThat(codec.isValidFrame(badHeader)).isFalse() + + // 2. 푸터 바이트 손상 + val badFooter = validFrame.copyOf() + badFooter[badFooter.size - 1] = 0x00 + assertThat(codec.decode(badFooter)).isNull() + assertThat(codec.isValidFrame(badFooter)).isFalse() + + // 3. 길이 필드 변조 + val badLen = validFrame.copyOf() + badLen[2] = (badLen[2] + 1).toByte() + assertThat(codec.decode(badLen)).isNull() + assertThat(codec.isValidFrame(badLen)).isFalse() + + // 4. 체크섬 바이트 변조 + val badChecksum = validFrame.copyOf() + badChecksum[badChecksum.size - 2] = (badChecksum[badChecksum.size - 2] + 1).toByte() + assertThat(codec.decode(badChecksum)).isNull() + assertThat(codec.isValidFrame(badChecksum)).isFalse() + + // 5. 프레임 길이가 너무 짧은 경우 (5바이트) + val tooShort = ByteArray(5) { 0xA1.toByte() } + assertThat(codec.decode(tooShort)).isNull() + assertThat(codec.isValidFrame(tooShort)).isFalse() + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt new file mode 100644 index 0000000..152c600 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt @@ -0,0 +1,93 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DctResult +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.data.repository.PacketRepositoryImpl +import com.dct.app.domain.model.protocol.* +import com.dct.app.domain.repository.DeviceConnectionState +import io.mockk.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class PacketRepositoryResponseTest { + private class Fixture(dispatcher: kotlinx.coroutines.CoroutineDispatcher) { + val wifi = mockk(relaxed = true) + val packets = MutableSharedFlow(extraBufferCapacity = 8) + val connected = MutableStateFlow(true) + var reject: Byte? = null + var versionPayload = byteArrayOf(1, 65) + init { + every { wifi.isConnected } returns connected + every { wifi.packetStream } returns packets + coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false } + coEvery { wifi.sendCommand(any()) } coAnswers { + val request = firstArg() + val payload = when (request.command.toInt()) { + 1 -> versionPayload + 3 -> ByteArray(6) + 9 -> byteArrayOf(6, if (reject == request.command) 2 else 1) + else -> byteArrayOf(if (reject == request.command) 2 else 1) + } + packets.emit(DctPacket(command = (request.command + 0x80).toByte(), payload = payload)) + true + } + } + val repository = PacketRepositoryImpl(wifiSocketManager = wifi, ioDispatcher = dispatcher, responseTimeoutMs = 50) + } + + @Test fun negativeEnableNeverReachesReady() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + f.reject = 0 + assertTrue(f.repository.connect("") is DctResult.Failure) + assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) + coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 1.toByte() }) } + } + @Test fun existingDctTestVersionCompletesHandshake() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + f.versionPayload = "DCT-MOBI-v1.0.0\u0000".toByteArray(Charsets.US_ASCII) + assertTrue(f.repository.connect("") is DctResult.Success) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + coVerify(exactly = 1) { f.wifi.sendCommand(match { it.command == 3.toByte() }) } + coVerify(exactly = 1) { f.wifi.sendCommand(match { it.command == 4.toByte() }) } + } + @Test fun correctedDctTestVersionCompletesHandshake() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + val version = "DCT-MOBI-v1.0.0".toByteArray(Charsets.US_ASCII) + f.versionPayload = byteArrayOf(version.size.toByte()) + version + assertTrue(f.repository.connect("") is DctResult.Success) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + } + @Test fun truncatedVersionStillStopsHandshakeBeforeHardwareId() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + f.versionPayload = byteArrayOf(15) + "DCT-MOBI-v1.0".toByteArray(Charsets.US_ASCII) + assertTrue(f.repository.connect("") is DctResult.Failure) + assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) + coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 3.toByte() }) } + } + @Test fun configurationStopsAtNegativeAck() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + f.reject = 9 + assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure) + coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 0x10.toByte() }) } + } + @Test fun timeoutClosesTransportSoLateAckCannotApproveNextWrite() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + coEvery { f.wifi.sendCommand(any()) } returns true + assertTrue(f.repository.sendCommand(DctProtocolRequests.constantWrite(0x1000, byteArrayOf(1))) is DctResult.Failure) + coVerify(exactly = 1) { f.wifi.disconnectSuspend() } + assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) + assertFalse(f.connected.value) + } + @Test fun disableDoesNotWaitForUndefined8fResponse() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + coEvery { f.wifi.sendCommand(any()) } returns true + val before = testScheduler.currentTime + assertTrue(f.repository.sendCommand(DctProtocolRequests.disable()) is DctResult.Success) + assertEquals(before, testScheduler.currentTime) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/SMLinEvaluatorTest.kt b/Android/app/src/test/java/com/dct/app/tests/SMLinEvaluatorTest.kt new file mode 100644 index 0000000..fc6c116 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/SMLinEvaluatorTest.kt @@ -0,0 +1,72 @@ +package com.dct.app.tests + +import com.dct.app.core.formula.FormulaEvaluator +import com.dct.app.core.formula.SMLinEvaluator +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class SMLinEvaluatorTest { + + @Test + @DisplayName("기본 2점 선형 보간 (0V -> 0bar, 5V -> 100bar) 계산 검증") + fun testTwoPointLinearInterpolation() { + val vars = mapOf("01-01 ch" to 2.5) + val res = SMLinEvaluator.evaluate("LIN(\"01-01\"; 0.0; 0.0; 5.0; 100.0)", vars) + assertEquals(50.0, res, 0.001) + } + + @Test + @DisplayName("다중 구간(Piecewise) 선형 보간 검증") + fun testPiecewiseLinearInterpolation() { + val expr = "LIN(\"01-02\"; 0.0; 0.0; 2.0; 20.0; 4.0; 60.0; 10.0; 100.0)" + + assertEquals(10.0, SMLinEvaluator.evaluate(expr, mapOf("01-02 ch" to 1.0)), 0.001) + assertEquals(40.0, SMLinEvaluator.evaluate(expr, mapOf("01-02 ch" to 3.0)), 0.001) + assertEquals(80.0, SMLinEvaluator.evaluate(expr, mapOf("01-02 ch" to 7.0)), 0.001) + } + + @Test + @DisplayName("범위 외 값(최솟값 미만 / 최댓값 초과) 클램핑 검증") + fun testOutOfRangeClamping() { + val expr = "LIN(\"01-01\"; 1.0; 10.0; 5.0; 50.0)" + + // 최솟값 미만 -> y1 반환 + assertEquals(10.0, SMLinEvaluator.evaluate(expr, mapOf("01-01 ch" to -5.0)), 0.001) + assertEquals(10.0, SMLinEvaluator.evaluate(expr, mapOf("01-01 ch" to 1.0)), 0.001) + + // 최댓값 초과 -> yn 반환 + assertEquals(50.0, SMLinEvaluator.evaluate(expr, mapOf("01-01 ch" to 5.0)), 0.001) + assertEquals(50.0, SMLinEvaluator.evaluate(expr, mapOf("01-01 ch" to 10.0)), 0.001) + } + + @Test + @DisplayName("채널명 별칭(\"01-01\", \"01-01 ch\", \"sm_01_01\") 식별 검증") + fun testChannelAliases() { + val vars = mapOf("sm_02_05" to 3.0) + val res = SMLinEvaluator.evaluate("LIN(\"02-05\"; 0.0; 0.0; 6.0; 120.0)", vars) + assertEquals(60.0, res, 0.001) + } + + @Test + @DisplayName("FormulaEvaluator와 LIN 수식 결합 평가 검증") + fun testFormulaEvaluatorIntegration() { + val telemetry = mapOf("01-01 ch" to 2.5) + val res = FormulaEvaluator.evaluate("LIN(\"01-01\"; 0.0; 0.0; 5.0; 100.0)", telemetry) + assertEquals(50.0, res, 0.001) + + assertTrue(FormulaEvaluator.validate("LIN(\"01-01\"; 0.0; 0.0; 5.0; 100.0)")) + } + + @Test + @DisplayName("잘못된 문법 및 미연결 채널에 대한 NaN 반환 검증") + fun testInvalidExpressionHandling() { + assertEquals(Double.NaN, SMLinEvaluator.evaluate("LIN()", emptyMap())) + assertEquals(Double.NaN, SMLinEvaluator.evaluate("LIN(\"01-01\"; 1.0)", emptyMap())) + assertEquals(Double.NaN, SMLinEvaluator.evaluate("LIN(\"01-01\"; 1.0; 2.0)", mapOf("01-01 ch" to 1.5))) // 점이 1개뿐 + assertEquals(Double.NaN, SMLinEvaluator.evaluate("LIN(\"01-01\"; 1.0; 2.0; 3.0; 4.0)", emptyMap())) // 변수 없음 + assertTrue(SMLinEvaluator.evaluate("LIN(\"01-01\", 0.0, 0.0, 5.0, 100.0)", mapOf("01-01 ch" to 2.5)).isNaN()) + assertTrue(SMLinEvaluator.evaluate("LIN(\"01-01\"; 5.0; 100.0; 0.0; 0.0)", mapOf("01-01 ch" to 2.5)).isNaN()) + assertTrue(SMLinEvaluator.evaluate("LIN(\"01-01\"; 1.0; 10.0; 1.0; 20.0)", mapOf("01-01 ch" to 1.0)).isNaN()) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/SensorModuleIntegrationTest.kt b/Android/app/src/test/java/com/dct/app/tests/SensorModuleIntegrationTest.kt new file mode 100644 index 0000000..d1bf0e7 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/SensorModuleIntegrationTest.kt @@ -0,0 +1,90 @@ +package com.dct.app.tests + +import com.dct.app.data.logger.CsvMeasurementLogger +import com.dct.app.data.logger.Mf4MeasurementLogger +import com.dct.app.data.repository.DpcProjectManager +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.domain.model.project.DpcSensorModuleChannel +import com.dct.app.domain.model.project.DpcSensorModuleConfig +import com.dct.app.domain.model.project.createDefaultSensorModules +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class SensorModuleIntegrationTest { + + @Test + @DisplayName("DpcProject JSON 직렬화/역직렬화 시 4개 센서 모듈 및 64개 채널 속성이 100% 보존되어야 한다") + fun testSensorModuleProjectSerialization() { + val modules = createDefaultSensorModules().toMutableList() + // SM 1 채널 1: 전압 센서 + val ch1 = DpcSensorModuleChannel( + channelIndex = 0, + name = "01-01 ch", + isVoltage = true, + unit = "V", + description = "배터리 모니터링 전압", + color = 0xFFFF5722L + ) + val chList = modules[0].channels.toMutableList() + chList[0] = ch1 + modules[0] = modules[0].copy(isUsed = true, channels = chList) + + // SM 2 활성화 + modules[1] = modules[1].copy(isUsed = true) + + val project = DpcProject( + projectFileName = "SM_TEST_PROJECT.dpc", + sensorModules = modules, + smConfigured = true + ) + + val json = DpcProjectManager.serializeProjectToJson(project) + assertTrue(json.contains("01-01 ch")) + assertTrue(json.contains("배터리 모니터링 전압")) + + val parsed = DpcProjectManager.parseProjectFromJson(json, null) + assertEquals(4, parsed.sensorModules.size) + assertTrue(parsed.sensorModules[0].isUsed) + assertTrue(parsed.sensorModules[1].isUsed) + assertFalse(parsed.sensorModules[2].isUsed) + + val parsedCh1 = parsed.sensorModules[0].channels[0] + assertEquals("01-01 ch", parsedCh1.name) + assertTrue(parsedCh1.isVoltage) + assertEquals("V", parsedCh1.unit) + assertEquals("배터리 모니터링 전압", parsedCh1.description) + } + + @Test + @DisplayName("Sensor Module 계측 데이터가 실시간 CSV 및 MF4 로거 파일에 _sm 접미사로 기록되어야 한다") + fun testSensorModuleLogging(@TempDir tempDir: File) { + val smHeaders = listOf("01-01 ch" to "'C", "01-02 ch" to "V") + val csvName = "SM_PROJ_2026-08-27_14-00-00_sm.csv" + val mf4Name = "SM_PROJ_2026-08-27_14-00-00_sm.mf4" + + val csvLogger = CsvMeasurementLogger(tempDir, smHeaders, csvName) + val csvFile = csvLogger.start() + assertNotNull(csvFile) + assertEquals(csvName, csvFile!!.name) + + csvLogger.logSample(0.1, listOf(24.5, 3.25)) + csvLogger.stop() + + val csvContent = csvFile.readText() + assertTrue(csvContent.contains("01-01 ch")) + assertTrue(csvContent.contains("01-02 ch")) + assertTrue(csvContent.contains("24.5")) + + val mf4Logger = Mf4MeasurementLogger(tempDir, smHeaders, mf4Name) + val mf4File = mf4Logger.start() + assertNotNull(mf4File) + assertEquals(mf4Name, mf4File!!.name) + + mf4Logger.logSample(0.1, listOf(24.5, 3.25)) + mf4Logger.stop() + assertTrue(mf4File.length() > 0) + } +} \ No newline at end of file diff --git a/Android/app/src/test/java/com/dct/app/tests/SensorModulePacketTest.kt b/Android/app/src/test/java/com/dct/app/tests/SensorModulePacketTest.kt new file mode 100644 index 0000000..7acdea8 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/SensorModulePacketTest.kt @@ -0,0 +1,75 @@ +package com.dct.app.tests + +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.domain.model.protocol.DctCommand +import com.dct.app.domain.model.protocol.DctPacket +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class SensorModulePacketTest { + + private val codec = DctPacketCodec() + + @Test + @DisplayName("SEND_SM_DATA (0xA4) 64채널 패킷 인코딩/디코딩 및 물리값 계산 검증") + fun testSensorModuleDataPacket() { + // 채널 1: 24.50도 (int: 24 -> 0x0018, dec: 50 -> 0x0032) + // 채널 2: -15.75도 (int: -15 -> 0x800F, dec: 75 -> 0x004B) + // 채널 3: NC 미연결 (int: 0xFFFF, dec: 0x0000) + val payload = ByteArray(12) + + // CH 1 + payload[0] = 0x18.toByte() + payload[1] = 0x00.toByte() + payload[2] = 0x32.toByte() + payload[3] = 0x00.toByte() + + // CH 2 + payload[4] = 0x0F.toByte() + payload[5] = 0x80.toByte() + payload[6] = 0x4B.toByte() + payload[7] = 0x00.toByte() + + // CH 3 + payload[8] = 0xFF.toByte() + payload[9] = 0xFF.toByte() + payload[10] = 0x00.toByte() + payload[11] = 0x00.toByte() + + val packet = DctPacket(command = DctCommand.SEND_SM_DATA.code, payload = payload) + val encoded = codec.encode(packet) + val decoded = codec.decode(encoded) + + assertNotNull(decoded) + assertEquals(DctCommand.SEND_SM_DATA.code, decoded!!.command) + + val p = decoded.payload + // CH 1 decode + val int1 = ((p[1].toInt() and 0xFF) shl 8) or (p[0].toInt() and 0xFF) + val dec1 = ((p[3].toInt() and 0xFF) shl 8) or (p[2].toInt() and 0xFF) + assertEquals(24.50, int1 + dec1 / 100.0, 0.001) + + // CH 2 decode (Negative) + val int2 = ((p[5].toInt() and 0xFF) shl 8) or (p[4].toInt() and 0xFF) + val dec2 = ((p[7].toInt() and 0xFF) shl 8) or (p[6].toInt() and 0xFF) + val isMinus = (int2 and 0x8000) != 0 + val realInt2 = (int2 and 0x7FFF) + var val2 = realInt2 + dec2 / 100.0 + if (isMinus) val2 = -val2 + assertEquals(-15.75, val2, 0.001) + + // CH 3 decode (NC) + val int3 = ((p[9].toInt() and 0xFF) shl 8) or (p[8].toInt() and 0xFF) + assertEquals(0xFFFF, int3) + } + + @Test + @DisplayName("CMD_SM_INFO (0x12) 및 RES_SM_INFO (0x92) 커맨드 매핑 검증") + fun testSmInfoCommands() { + assertEquals(0x12.toByte(), DctCommand.CMD_SM_INFO.code) + assertEquals(0x92.toByte(), DctCommand.RES_SM_INFO.code) + assertFalse(DctCommand.isResponse(DctCommand.CMD_SM_INFO.code)) + assertTrue(DctCommand.isResponse(DctCommand.RES_SM_INFO.code)) + } +} \ No newline at end of file diff --git a/Android/app/src/test/java/com/dct/app/tests/SignalDragDropManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/SignalDragDropManagerTest.kt new file mode 100644 index 0000000..e6644d4 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/SignalDragDropManagerTest.kt @@ -0,0 +1,97 @@ +package com.dct.app.tests + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import com.dct.app.presentation.chart.DraggedSignal +import com.dct.app.presentation.chart.SignalDragDropManager +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("SignalDragDropManager - 신호 드래그 앤 드롭 상태 관리자 단위 테스트") +class SignalDragDropManagerTest { + + @BeforeEach + fun setUp() { + SignalDragDropManager.cancelDrag() + } + + @Test + @DisplayName("드래그 시작 시 isDragging이 true가 되고 드래그 신호 정보가 저장되어야 한다") + fun testStartDrag() { + val signal = DraggedSignal( + signalId = "sig_1_1", + signalName = "EngineSpeed", + colorLong = 0xFF0000FF, + unit = "RPM", + isCan = false + ) + SignalDragDropManager.startDrag(signal, Offset(100f, 200f)) + + assertTrue(SignalDragDropManager.isDragging) + assertEquals("EngineSpeed", SignalDragDropManager.draggedSignal?.signalName) + assertEquals(Offset(100f, 200f), SignalDragDropManager.dragPosition) + } + + @Test + @DisplayName("드래그 중 위치 이동(updateDrag)이 정상적으로 누적되어야 한다") + fun testUpdateDrag() { + val signal = DraggedSignal( + signalId = "can_10", + signalName = "VehicleSpeed", + colorLong = 0xFFFF0000, + unit = "km/h", + isCan = true + ) + SignalDragDropManager.startDrag(signal, Offset(100f, 100f)) + SignalDragDropManager.updateDrag(Offset(50f, 30f)) + + assertEquals(Offset(150f, 130f), SignalDragDropManager.dragPosition) + } + + @Test + @DisplayName("드롭 타겟 영역 내에서 endDrag 호출 시 콜백이 실행되어야 한다") + fun testEndDragOnDropTarget() { + val signal = DraggedSignal( + signalId = "a2l_100", + signalName = "CoolantTemp", + colorLong = 0xFF00FF00, + unit = "degC", + isCan = false, + isA2l = true + ) + SignalDragDropManager.registerDropTarget("chart_1", Rect(0f, 0f, 500f, 500f)) + SignalDragDropManager.startDrag(signal, Offset(200f, 200f)) + + var droppedSignal: DraggedSignal? = null + SignalDragDropManager.endDrag { dropped -> + droppedSignal = dropped + } + + assertNotNull(droppedSignal) + assertEquals("CoolantTemp", droppedSignal?.signalName) + assertFalse(SignalDragDropManager.isDragging) + assertNull(SignalDragDropManager.draggedSignal) + + SignalDragDropManager.unregisterDropTarget("chart_1") + } + + @Test + @DisplayName("드래그 취소(cancelDrag) 시 상태가 초기화되어야 한다") + fun testCancelDrag() { + val signal = DraggedSignal( + signalId = "sig_1_1", + signalName = "TestSignal", + colorLong = 0, + unit = "", + isCan = false + ) + SignalDragDropManager.startDrag(signal, Offset(100f, 100f)) + assertTrue(SignalDragDropManager.isDragging) + + SignalDragDropManager.cancelDrag() + assertFalse(SignalDragDropManager.isDragging) + assertNull(SignalDragDropManager.draggedSignal) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/SignalTreeViewModelTest.kt b/Android/app/src/test/java/com/dct/app/tests/SignalTreeViewModelTest.kt new file mode 100644 index 0000000..855847f --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/SignalTreeViewModelTest.kt @@ -0,0 +1,292 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DefaultDispatcherProvider +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.mdb.* +import com.dct.app.presentation.signaltree.SignalTreeFilterMode +import com.dct.app.presentation.signaltree.SignalTreeNodeType +import com.dct.app.presentation.signaltree.SignalTreeViewModel +import com.dct.app.presentation.signaltree.TreeItemSelection +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +@DisplayName("M5 - SignalTreeViewModel 신호 트리 탐색기 단위 테스트") +class SignalTreeViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var dispatchers: TestDispatcherProvider + private lateinit var viewModel: SignalTreeViewModel + private lateinit var sampleDb: MdbDatabase + + @BeforeEach + fun setUp() { + Dispatchers.setMain(testDispatcher) + dispatchers = TestDispatcherProvider(testDispatcher) + viewModel = SignalTreeViewModel(dispatchers) + + // 샘플 테스트 MdbDatabase 구성 + val sig1 = SignalDefinition( + reclid = 1, + idx = 1, + name = "Ta_Engine_Speed", + attr = MdbAttrType.ATTR_2_UINT16, + cvsA = 0.25, + cvsB = 0.0, + unit = "RPM", + color = 0xFF0052CCL, + min = 0.0, + max = 8000.0, + description = "엔진 회전수" + ) + val sig2 = SignalDefinition( + reclid = 1, + idx = 2, + name = "Ta_Coolant_Temp", + attr = MdbAttrType.ATTR_1_INT8, + cvsA = 1.0, + cvsB = -40.0, + unit = "°C", + color = 0xFFBA1A1AL, + min = -40.0, + max = 120.0, + description = "냉각수 온도" + ) + val sig3 = SignalDefinition( + reclid = 2, + idx = 1, + name = "Tb_Battery_Volt", + attr = MdbAttrType.ATTR_2_UINT16, + cvsA = 0.1, + cvsB = 0.0, + unit = "V", + color = 0xFF00875AL, + min = 0.0, + max = 500.0, + description = "배터리 전압" + ) + + val group1 = SignalGroup( + reclid = 1, + strIndex = "G1_ENGINE", + description = "엔진 제어 그룹", + usage = true, + signals = listOf(sig1, sig2) + ) + val group2 = SignalGroup( + reclid = 2, + strIndex = "G2_BATTERY", + description = "배터리 전원 그룹", + usage = true, + signals = listOf(sig3) + ) + + val const1 = MdbConstant( + idx = 1, + name = "K_IDLE_RPM_TARGET", + data = 750.0, + addr = 0x1000L, + min = 500.0, + max = 1200.0, + attr = MdbAttrType.ATTR_2_UINT16, + description = "아이들 목표 RPM" + ) + + val mapPoints = listOf( + MapPoint(idx = 1, bkp = 1000, data = 50, phy = 12.5), + MapPoint(idx = 2, bkp = 2000, data = 100, phy = 25.0) + ) + val map1 = CompensationMap( + type = CompensationMapType.TYPE_A, + idx = 1, + yName = "MAP_TQ_OFFSET", + yAddr = 0x2000L, + yAttr = MdbAttrType.ATTR_2_UINT16, + yMin = 0.0, + yMax = 100.0, + xName = "RPM_AXIS", + xAddr = 0x2010L, + xAttr = MdbAttrType.ATTR_2_UINT16, + cvsA = 0.25, + cvsB = 0.0, + description = "토크 보정 맵", + points = mapPoints + ) + + sampleDb = MdbDatabase( + fileName = "Test_Dct.mdb", + groups = listOf(group1, group2), + constants = listOf(const1), + compensationMaps = listOf(map1) + ) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + @DisplayName("setDatabase 호출 시 신호, 상수, 보정 맵 계층 노드가 정확히 생성되어야 한다") + fun testBuildTreeFromDatabase() { + viewModel.setDatabase(sampleDb) + val state = viewModel.uiState.value + + assertEquals(3, state.totalSignalCount) + assertEquals(1, state.totalConstantCount) + assertEquals(1, state.totalMapCount) + + // 3개의 최상위 카테고리 (계측 신호, 제어 상수, 1D/2D 보정 맵) + assertEquals(3, state.rootNodes.size) + val sigCategory = state.rootNodes[0] + assertEquals("root_signals", sigCategory.id) + assertEquals(2, sigCategory.children.size) // 그룹 2개 + + val constCategory = state.rootNodes[1] + assertEquals("root_constants", constCategory.id) + assertEquals(1, constCategory.children.size) + + val mapCategory = state.rootNodes[2] + assertEquals("root_maps", mapCategory.id) + assertEquals(1, mapCategory.children.size) + } + + @Test + @DisplayName("신호명 검색 쿼리 입력 시 일치하는 노드만 필터링되어야 한다 (대소문자 무시)") + fun testSearchQueryFilter() { + viewModel.setDatabase(sampleDb) + + // "engine" 검색 -> Ta_Engine_Speed 및 해당 부모 그룹 포함 + viewModel.onSearchQueryChanged("engine") + + val state = viewModel.uiState.value + assertEquals("engine", state.searchQuery) + + val sigCategory = state.filteredNodes.find { it.id == "root_signals" } + assertNotNull(sigCategory) + + // 엔진 그룹만 매칭되어야 함 (배터리 그룹은 제외) + val engineGroup = sigCategory?.children?.find { it.id == "group_1" } + assertNotNull(engineGroup) + assertTrue(engineGroup?.children?.any { it.name == "Ta_Engine_Speed" } == true) + assertFalse(engineGroup?.children?.any { it.name == "Ta_Coolant_Temp" } == true) + } + + @Test + @DisplayName("단위(Unit)로 검색 쿼리 입력 시 단위가 일치하는 신호가 필터링되어야 한다") + fun testSearchByUnit() { + viewModel.setDatabase(sampleDb) + viewModel.onSearchQueryChanged("°C") + + val state = viewModel.uiState.value + val sigCategory = state.filteredNodes.find { it.id == "root_signals" } + val engineGroup = sigCategory?.children?.find { it.id == "group_1" } + + assertEquals(1, engineGroup?.children?.size) + assertEquals("Ta_Coolant_Temp", engineGroup?.children?.first()?.name) + } + + @Test + @DisplayName("필터 모드를 변경하면 해당 카테고리 노드만 표시되어야 한다") + fun testFilterModes() { + viewModel.setDatabase(sampleDb) + + // 1. CONSTANTS_ONLY + viewModel.onFilterModeChanged(SignalTreeFilterMode.CONSTANTS_ONLY) + val constState = viewModel.uiState.value + assertTrue(constState.filteredNodes.any { it.id == "root_constants" }) + assertFalse(constState.filteredNodes.any { it.id == "root_signals" }) + + // 2. MAPS_ONLY + viewModel.onFilterModeChanged(SignalTreeFilterMode.MAPS_ONLY) + val mapState = viewModel.uiState.value + assertTrue(mapState.filteredNodes.any { it.id == "root_maps" }) + assertFalse(mapState.filteredNodes.any { it.id == "root_signals" }) + + // 3. ALL + viewModel.onFilterModeChanged(SignalTreeFilterMode.ALL) + val allState = viewModel.uiState.value + assertEquals(3, allState.filteredNodes.size) + } + + @Test + @DisplayName("노드 접기/펼치기 토글이 정상적으로 동작해야 한다") + fun testToggleNodeExpansion() { + viewModel.setDatabase(sampleDb) + val groupId = "group_1" + + assertTrue(viewModel.uiState.value.expandedNodeIds.contains(groupId)) + + viewModel.toggleNodeExpansion(groupId) + assertFalse(viewModel.uiState.value.expandedNodeIds.contains(groupId)) + + viewModel.toggleNodeExpansion(groupId) + assertTrue(viewModel.uiState.value.expandedNodeIds.contains(groupId)) + } + + @Test + @DisplayName("전체 펼치기(expandAll)와 전체 접기(collapseAll)가 정상 동작해야 한다") + fun testExpandAndCollapseAll() { + viewModel.setDatabase(sampleDb) + + viewModel.collapseAll() + assertTrue(viewModel.uiState.value.expandedNodeIds.isEmpty()) + + viewModel.expandAll() + assertTrue(viewModel.uiState.value.expandedNodeIds.contains("root_signals")) + assertTrue(viewModel.uiState.value.expandedNodeIds.contains("group_1")) + assertTrue(viewModel.uiState.value.expandedNodeIds.contains("group_2")) + } + + @Test + @DisplayName("노드 선택 시 올바른 TreeItemSelection 페이로드가 발행되어야 한다") + fun testSelectNode() { + viewModel.setDatabase(sampleDb) + + // 1. 신호 노드 선택 + val sigNode = viewModel.uiState.value.rootNodes[0].children[0].children[0] + viewModel.selectNode(sigNode) + + val state = viewModel.uiState.value + assertEquals(sigNode.id, state.selectedNodeId) + assertTrue(state.selectedSelection is TreeItemSelection.SignalSelected) + val sigSelection = state.selectedSelection as TreeItemSelection.SignalSelected + assertEquals("Ta_Engine_Speed", sigSelection.signal.name) + + // 2. 상수 노드 선택 + val constNode = viewModel.uiState.value.rootNodes[1].children[0] + viewModel.selectNode(constNode) + assertTrue(viewModel.uiState.value.selectedSelection is TreeItemSelection.ConstantSelected) + } + + @Test + @DisplayName("차트 축 바인딩 요청(requestAddToChart) 시 Flow 이벤트가 정상 방출되어야 한다") + fun testRequestAddToChart() = runTest { + viewModel.setDatabase(sampleDb) + val sigNode = viewModel.uiState.value.rootNodes[0].children[0].children[0] + + var emittedReq = false + val job = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.addToChartEvents.collect { req -> + assertEquals("Ta_Engine_Speed", req.signalName) + assertEquals(AxisPosition.RIGHT_1, req.axisPosition) + assertEquals("RPM", req.unit) + emittedReq = true + } + } + + viewModel.requestAddToChart(sigNode, AxisPosition.RIGHT_1) + assertTrue(emittedReq) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt new file mode 100644 index 0000000..5076cdf --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt @@ -0,0 +1,220 @@ +package com.dct.app.tests + +import com.google.common.truth.Truth.assertThat +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.data.protocol.SlidingRingBufferParser +import com.dct.app.domain.model.protocol.DctCommand +import com.dct.app.domain.model.protocol.DctPacket +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream + +@DisplayName("SlidingRingBufferParser 스트림 파싱 및 복구 단위 테스트") +class SlidingRingBufferParserTest { + + private lateinit var parser: SlidingRingBufferParser + private lateinit var codec: DctPacketCodec + + @BeforeEach + fun setUp() { + parser = SlidingRingBufferParser() + codec = DctPacketCodec() + } + + @Test + @DisplayName("단일 완전한 패킷 입력 시 즉시 파싱 성공") + fun testParseSingleCompletePacket() { + val packet = DctPacket.create(DctCommand.CMD_ENABLE, byteArrayOf(0x01, 0x02)) + val encoded = codec.encode(packet) + + val result = parser.parseStream(encoded) + assertThat(result).hasSize(1) + assertThat(result[0].command).isEqualTo(DctCommand.CMD_ENABLE.code) + assertThat(result[0].payload).isEqualTo(byteArrayOf(0x01, 0x02)) + } + + @Test + @DisplayName("복수 패킷이 결합된 연속 스트림(Coalesced Packets) 일괄 파싱 검증") + fun testParseMultipleCoalescedPackets() { + val stream = ByteArrayOutputStream() + val packetsToSend = (1..20).map { seq -> + val p = DctPacket.create( + command = DctCommand.SEND_CAN_FRAME, + payload = byteArrayOf(seq.toByte(), 0x10, 0x20), + sequence = seq + ) + stream.write(codec.encode(p)) + p + } + + val allBytes = stream.toByteArray() + val parsedPackets = parser.parseStream(allBytes) + + assertThat(parsedPackets).hasSize(20) + for (i in 0 until 20) { + assertThat(parsedPackets[i].command).isEqualTo(DctCommand.SEND_CAN_FRAME.code) + assertThat(parsedPackets[i].payload).isEqualTo(packetsToSend[i].payload) + } + } + + @Test + @DisplayName("1바이트씩 극단적으로 쪼개져 들어오는 조각화 스트림(Fragmented Chunks) 완벽 조립") + fun testParseFragmentedOneByteAtATime() { + val packet = DctPacket.create( + command = DctCommand.CMD_VERSION, + payload = "TEST_VERSION_STRING_12345".toByteArray() + ) + val frame = codec.encode(packet) + + val accumulatedResult = mutableListOf() + for (b in frame) { + val res = parser.parseStream(byteArrayOf(b)) + accumulatedResult.addAll(res) + } + + assertThat(accumulatedResult).hasSize(1) + assertThat(accumulatedResult[0].command).isEqualTo(DctCommand.CMD_VERSION.code) + assertThat(accumulatedResult[0].payload).isEqualTo(packet.payload) + } + + @Test + @DisplayName("헤더 이전 및 패킷 사이 가비지(Noise) 바이트 자동 스킵 및 복구") + fun testGarbageBytesSkipping() { + val validPacket = DctPacket.create(DctCommand.CMD_HWID, byteArrayOf(0xAA.toByte(), 0xBB.toByte())) + val validFrame = codec.encode(validPacket) + + val stream = ByteArrayOutputStream() + // 1. 앞쪽 가비지 + stream.write(byteArrayOf(0x00, 0x12, 0x34, 0x7E, 0x55, 0x99.toByte())) + // 2. 정상 패킷 + stream.write(validFrame) + // 3. 중간 가비지 + stream.write(byteArrayOf(0xFE.toByte(), 0xDC.toByte(), 0x00)) + // 4. 또 다른 정상 패킷 + val validPacket2 = DctPacket.create(DctCommand.CMD_SYNC_TIME, byteArrayOf(0x01)) + stream.write(codec.encode(validPacket2)) + + val result = parser.parseStream(stream.toByteArray()) + assertThat(result).hasSize(2) + assertThat(result[0].command).isEqualTo(DctCommand.CMD_HWID.code) + assertThat(result[0].payload).isEqualTo(validPacket.payload) + assertThat(result[1].command).isEqualTo(DctCommand.CMD_SYNC_TIME.code) + assertThat(result[1].payload).isEqualTo(validPacket2.payload) + } + + @Test + @DisplayName("페이로드 내에 0x7E 및 0xA1 바이트가 포함되어 있어도 정상 파싱") + fun testPayloadContainingHeaderOrFooterBytes() { + // 페이로드에 0x7E와 0xA1이 마구 섞여 있는 경우 + val trickyPayload = byteArrayOf(0x7E, 0x7E, 0xA1.toByte(), 0x00, 0x7E, 0xA1.toByte()) + val packet = DctPacket.create(DctCommand.CMD_MDB_CONSTANT_DATA, trickyPayload) + val encoded = codec.encode(packet) + + val result = parser.parseStream(encoded) + assertThat(result).hasSize(1) + assertThat(result[0].command).isEqualTo(DctCommand.CMD_MDB_CONSTANT_DATA.code) + assertThat(result[0].payload).isEqualTo(trickyPayload) + } + + @Test + @DisplayName("체크섬 오류가 있는 손상된 패킷 폐기 후 바로 다음 정상 패킷 파싱") + fun testChecksumCorruptedPacketRecovery() { + val goodPacket1 = DctPacket.create(DctCommand.CMD_ENABLE) + val goodFrame1 = codec.encode(goodPacket1) + + val badPacket = DctPacket.create(DctCommand.CMD_VERSION, "BAD".toByteArray()) + val badFrame = codec.encode(badPacket) + badFrame[badFrame.size - 2] = (badFrame[badFrame.size - 2] + 1).toByte() // 체크섬 고의 변조 + + val goodPacket2 = DctPacket.create(DctCommand.CMD_CARTYPE, "MQ4".toByteArray()) + val goodFrame2 = codec.encode(goodPacket2) + + val stream = ByteArrayOutputStream() + stream.write(goodFrame1) + stream.write(badFrame) + stream.write(goodFrame2) + + val result = parser.parseStream(stream.toByteArray()) + // badFrame은 체크섬 실패로 드롭되어야 함 + assertThat(result).hasSize(2) + assertThat(result[0].command).isEqualTo(DctCommand.CMD_ENABLE.code) + assertThat(result[1].command).isEqualTo(DctCommand.CMD_CARTYPE.code) + assertThat(result[1].payload).isEqualTo("MQ4".toByteArray()) + } + + @Test + @DisplayName("pollPacket 및 reset 메서드 동작 검증") + fun testPollPacketAndReset() { + val packet = DctPacket.create(DctCommand.CMD_CUR_STATUS) + parser.parseStream(codec.encode(packet)) + + assertThat(parser.queuedPacketCount).isEqualTo(1) + val polled = parser.pollPacket() + assertThat(polled).isNotNull() + assertThat(polled?.command).isEqualTo(DctCommand.CMD_CUR_STATUS.code) + assertThat(parser.queuedPacketCount).isEqualTo(0) + + // Reset 검증 + parser.parseStream(codec.encode(packet)) + assertThat(parser.queuedPacketCount).isEqualTo(1) + parser.reset() + assertThat(parser.queuedPacketCount).isEqualTo(0) + assertThat(parser.availableByteCount).isEqualTo(0) + } + + @Test + @DisplayName("가짜 0xA1 헤더(푸터 불일치) 유입 시 1바이트 백트래킹으로 후속 정상 패킷 100% 복구") + fun testFalseA1HeaderRollbackAndSubsequentPacketRecovery() { + // 가짜 0xA1 프레임: 0xA1 헤더에 packLen=6이지만 끝이 0x7E가 아님 + val fakeFrame = byteArrayOf(0xA1.toByte(), 0x00, 0x06, 0x10, 0x20, 0x30, 0x40, 0x50, 0x00, 0x11, 0x22) + val validPacket = DctPacket.create(DctCommand.CMD_VERSION, "GENUINE_RECOVERED".toByteArray()) + val validFrame = codec.encode(validPacket) + + val stream = ByteArrayOutputStream() + stream.write(fakeFrame) + stream.write(validFrame) + + val result = parser.parseStream(stream.toByteArray()) + assertThat(result).hasSize(1) + assertThat(result[0].command).isEqualTo(DctCommand.CMD_VERSION.code) + assertThat(result[0].payload).isEqualTo("GENUINE_RECOVERED".toByteArray()) + } + + @Test + @DisplayName("연속된 다중 가짜 0xA1 노이즈 유입 시 완벽한 백트래킹 순환 및 정상 패킷 복구") + fun testConsecutiveFalseA1HeadersBacktracking() { + val stream = ByteArrayOutputStream() + // 가짜 0xA1 바이트 5개 연속 + stream.write(byteArrayOf(0xA1.toByte(), 0xA1.toByte(), 0xA1.toByte(), 0xA1.toByte(), 0xA1.toByte())) + + val validPacket = DctPacket.create(DctCommand.CMD_CARTYPE, "TEST_CAR".toByteArray()) + stream.write(codec.encode(validPacket)) + + val result = parser.parseStream(stream.toByteArray()) + assertThat(result).hasSize(1) + assertThat(result[0].command).isEqualTo(DctCommand.CMD_CARTYPE.code) + assertThat(result[0].payload).isEqualTo("TEST_CAR".toByteArray()) + } + + @Test + @DisplayName("비정상적인 packLen(0 또는 초과 크기) 가짜 헤더 유입 시 즉시 1바이트 백트래킹") + fun testFalseHeaderLengthOutOfBoundsBacktracking() { + // packLen = 0x0000 (최소 1 이상이어야 함) + val invalidLenFrame1 = byteArrayOf(0xA1.toByte(), 0x00, 0x00, 0x00, 0x7E) + // packLen = 0xFFFF (maxPacketSize 초과) + val invalidLenFrame2 = byteArrayOf(0xA1.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0x00, 0x7E) + + val validPacket = DctPacket.create(DctCommand.CMD_ENABLE) + val validFrame = codec.encode(validPacket) + + val stream = ByteArrayOutputStream() + stream.write(invalidLenFrame1, 0, invalidLenFrame1.size) + stream.write(invalidLenFrame2, 0, invalidLenFrame2.size) + stream.write(validFrame, 0, validFrame.size) + + val result = parser.parseStream(stream.toByteArray()) + assertThat(result).hasSize(1) + assertThat(result[0].command).isEqualTo(DctCommand.CMD_ENABLE.code) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/TestDispatcherProvider.kt b/Android/app/src/test/java/com/dct/app/tests/TestDispatcherProvider.kt new file mode 100644 index 0000000..a0cbfa5 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/TestDispatcherProvider.kt @@ -0,0 +1,14 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DispatcherProvider +import kotlinx.coroutines.CoroutineDispatcher + +class TestDispatcherProvider( + private val testDispatcher: CoroutineDispatcher +) : DispatcherProvider { + override val main: CoroutineDispatcher = testDispatcher + override val mainImmediate: CoroutineDispatcher = testDispatcher + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val unconfined: CoroutineDispatcher = testDispatcher +} diff --git a/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt b/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt new file mode 100644 index 0000000..1b66b2b --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt @@ -0,0 +1,142 @@ +package com.dct.app.tests + +import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import kotlin.system.measureTimeMillis + +@DisplayName("TimeSeriesRingBuffer 고성능 제로-GC 원형 버퍼 단위 테스트") +class TimeSeriesRingBufferTest { + + @Test + @DisplayName("용량 미만 추가 시 정확한 크기와 시간/값 순서가 보존되어야 한다") + fun testUnderCapacityAddition() { + val buffer = TimeSeriesRingBuffer(10) + assertEquals(0, buffer.size) + + buffer.addSample(1.0, 100.0) + buffer.addSample(2.0, 200.0) + buffer.addSample(3.0, 300.0) + + assertEquals(3, buffer.size) + assertEquals(1.0, buffer.getTimestampAt(0), 1e-6) + assertEquals(100.0, buffer.getValueAt(0), 1e-6) + + assertEquals(2.0, buffer.getTimestampAt(1), 1e-6) + assertEquals(200.0, buffer.getValueAt(1), 1e-6) + + assertEquals(3.0, buffer.getTimestampAt(2), 1e-6) + assertEquals(300.0, buffer.getValueAt(2), 1e-6) + + assertEquals(1.0, buffer.getOldestTimestamp()!!, 1e-6) + assertEquals(3.0, buffer.getLatestTimestamp()!!, 1e-6) + assertEquals(300.0, buffer.getLatestValue()!!, 1e-6) + } + + @Test + @DisplayName("용량 초과 시 가장 오래된 데이터가 FIFO로 덮어쓰여지고 head가 전진해야 한다") + fun testOverCapacityFifoOverwrite() { + val buffer = TimeSeriesRingBuffer(5) + + // 1..5 추가 (가득 참) + for (i in 1..5) { + buffer.addSample(i.toDouble(), (i * 10).toDouble()) + } + assertEquals(5, buffer.size) + assertEquals(1.0, buffer.getTimestampAt(0), 1e-6) + assertEquals(10.0, buffer.getValueAt(0), 1e-6) + + // 6 추가 (1이 덮어쓰여지고 2가 0번 인덱스가 됨) + buffer.addSample(6.0, 60.0) + assertEquals(5, buffer.size) + assertEquals(2.0, buffer.getTimestampAt(0), 1e-6) + assertEquals(20.0, buffer.getValueAt(0), 1e-6) + assertEquals(6.0, buffer.getTimestampAt(4), 1e-6) + assertEquals(60.0, buffer.getValueAt(4), 1e-6) + + // 7, 8 추가 + buffer.addSample(7.0, 70.0) + buffer.addSample(8.0, 80.0) + assertEquals(5, buffer.size) + assertEquals(4.0, buffer.getTimestampAt(0), 1e-6) + assertEquals(40.0, buffer.getValueAt(0), 1e-6) + assertEquals(8.0, buffer.getTimestampAt(4), 1e-6) + assertEquals(80.0, buffer.getValueAt(4), 1e-6) + } + + @Test + @DisplayName("10만 개 대량 추가 시 메모리 누수 없이 O(1) 원소 접근이 가능해야 한다") + fun testLargeScaleInsertionAndAccessPerformance() { + val capacity = 100_000 + val buffer = TimeSeriesRingBuffer(capacity) + + val elapsed = measureTimeMillis { + for (i in 0 until 150_000) { + buffer.addSample(i * 0.01, (i % 1000).toDouble()) + } + } + + assertEquals(capacity, buffer.size) + // 15만 개 추가 시 가장 오래된 것은 50,000번째 (timestamp: 500.0) + assertEquals(500.0, buffer.getTimestampAt(0), 1e-6) + assertEquals((50000 % 1000).toDouble(), buffer.getValueAt(0), 1e-6) + + // 가장 최신은 149,999번째 (timestamp: 1499.99) + assertEquals(1499.99, buffer.getTimestampAt(capacity - 1), 1e-6) + + // 15만 개 삽입 시간이 500ms 미만이어야 함 + assertTrue(elapsed < 500, "15만개 추가 시간이 500ms 이내여야 합니다 (소요: ${elapsed}ms)") + } + + @Test + @DisplayName("clear() 호출 시 버퍼 상태가 초기화되어야 한다") + fun testClearBuffer() { + val buffer = TimeSeriesRingBuffer(10) + for (i in 1..10) { + buffer.addSample(i.toDouble(), i.toDouble()) + } + assertEquals(10, buffer.size) + + buffer.clear() + assertEquals(0, buffer.size) + assertNull(buffer.getLatestTimestamp()) + assertNull(buffer.getOldestValue()) + assertThrows(IndexOutOfBoundsException::class.java) { + buffer.getTimestampAt(0) + } + + // 초기화 후 재추가 시 정상 동작 + buffer.addSample(100.0, 55.5) + assertEquals(1, buffer.size) + assertEquals(100.0, buffer.getTimestampAt(0), 1e-6) + assertEquals(55.5, buffer.getValueAt(0), 1e-6) + } + + @Test + @DisplayName("선형 보간(interpolateAt)이 정확한 중간값을 계산해야 한다") + fun testLinearInterpolation() { + val buffer = TimeSeriesRingBuffer(10) + buffer.addSample(10.0, 100.0) + buffer.addSample(20.0, 200.0) + buffer.addSample(30.0, 400.0) + + // 1. 정확한 지점 + assertEquals(100.0, buffer.interpolateAt(10.0)!!, 1e-6) + assertEquals(200.0, buffer.interpolateAt(20.0)!!, 1e-6) + + // 2. 중간 지점 (15.0 -> 150.0) + assertEquals(150.0, buffer.interpolateAt(15.0)!!, 1e-6) + + // 3. 중간 지점 (25.0 -> 300.0) + assertEquals(300.0, buffer.interpolateAt(25.0)!!, 1e-6) + + // 4. 범위 밖 클램핑 + assertEquals(100.0, buffer.interpolateAt(5.0)!!, 1e-6) + assertEquals(400.0, buffer.interpolateAt(40.0)!!, 1e-6) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt b/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt new file mode 100644 index 0000000..e6d6cec --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt @@ -0,0 +1,108 @@ +package com.dct.app.tests + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.Velocity +import com.dct.app.presentation.monitor.components.TwoFingerFling +import com.dct.app.presentation.monitor.components.flingWorkspace +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class TwoFingerFlingTest { + private fun moving(limit: Float = 10000f) = TwoFingerFling(limit).apply { + for (i in 0..8) add(i * 10L, Offset(300f - i * 10f, 300f - i * 5f)) + } + @Test fun releaseKeepsCentroidSpeedWithScrollDirectionAndNoGain() { + val fling = moving() + fling.release(90, true) + val velocity = fling.finish(100) + assertEquals(1000f, velocity.x, 1f) + assertEquals(500f, velocity.y, 1f) + } + @Test fun platformMaximumVelocityIsRespectedPerAxis() { + val fling = moving(300f) + fling.release(80, true) + assertEquals(Velocity(300f, 300f), fling.finish(80)) + } + @Test fun stationaryGestureDoesNotFling() { + val fling = TwoFingerFling(10000f) + for (i in 0..8) fling.add(i * 10L, Offset(20f, 20f)) + fling.release(90, true) + assertEquals(0f, fling.finish(90).x, .01f) + assertEquals(0f, fling.finish(90).y, .01f) + } + @Test fun pausedMovementDoesNotReuseStaleVelocity() { + val fling = moving() + fling.release(200, true) + assertEquals(Velocity.Zero, fling.finish(210)) + } + @Test fun pinchOrUnconfirmedGestureDoesNotFling() { + val fling = moving() + fling.release(90, false) + assertEquals(Velocity.Zero, fling.finish(100)) + } + @Test fun holdingRemainingFingerSuppressesDelayedFling() { + val fling = moving() + fling.release(90, true) + assertEquals(Velocity.Zero, fling.finish(200)) + } + @Test fun remainingFingerCannotOverwriteCentroidVelocity() { + val fling = moving() + fling.release(90, true) + fling.add(100, Offset(-2000f, 2000f)) + assertEquals(1000f, fling.finish(100).x, 1f) + assertEquals(500f, fling.finish(100).y, 1f) + } + @Test fun cancellationOrThirdFingerCannotRestartFling() { + val fling = moving() + fling.release(90, true) + fling.cancel() + fling.release(100, true) + assertEquals(Velocity.Zero, fling.finish(100)) + } + @Test fun incompleteGestureCannotFling() { + assertEquals(Velocity.Zero, moving().finish(90)) + } + @Test fun suppliedAxisBehaviorsReceiveUnscaledVelocityAndScrollIndependently() = runTest { + val x = ScrollState(0) + val y = ScrollState(0) + val velocities = mutableListOf() + val behavior = object : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + velocities += initialVelocity + scrollBy(initialVelocity / 10f) + return 0f + } + } + flingWorkspace(x, y, behavior, behavior, Velocity(1000f, 500f)) + assertEquals(listOf(1000f, 500f), velocities) + assertEquals(100, x.value) + assertEquals(50, y.value) + } + @Test fun cancellationStopsBothAxisAnimations() = runTest { + val x = ScrollState(0) + val y = ScrollState(0) + var stopped = 0 + val behavior = object : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + try { awaitCancellation() } finally { stopped++ } + } + } + val job = launch { flingWorkspace(x, y, behavior, behavior, Velocity(1000f, 500f)) } + runCurrent() + assertTrue(x.isScrollInProgress) + assertTrue(y.isScrollInProgress) + job.cancel() + runCurrent() + assertEquals(2, stopped) + assertFalse(x.isScrollInProgress) + assertFalse(y.isScrollInProgress) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt b/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt new file mode 100644 index 0000000..681b073 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt @@ -0,0 +1,79 @@ +package com.dct.app.tests + +import com.dct.app.presentation.monitor.components.TwoFingerGesture +import com.dct.app.presentation.monitor.components.WorkspaceTouchNavigation +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class TwoFingerGestureTest { + private fun gesture(zoom: Boolean = true) = TwoFingerGesture(10f, zoom).apply { update(100f, 100f, 100f) } + + @Test fun firstContactAndSmallJitterDoNotMoveAnything() { + val g = gesture() + assertEquals(TwoFingerGesture.Motion(), g.update(105f, 104f, 108f)) + assertEquals(TwoFingerGesture.Mode.Pending, g.mode) + } + @Test fun translationCrossesSlopThenFollowsBothAxesOneToOne() { + val g = gesture() + val first = g.update(130f, 140f, 100f) + assertEquals(24f, first.dx) + assertEquals(32f, first.dy) + assertEquals(TwoFingerGesture.Motion(3f, -4f), g.update(133f, 136f, 100f)) + assertEquals(TwoFingerGesture.Mode.Pan, g.mode) + } + @Test fun zoomUsesSeparationAndNeverEmitsPan() { + val g = gesture() + assertEquals(TwoFingerGesture.Motion(zoom = 1.4f), g.update(100f, 100f, 140f)) + assertEquals(TwoFingerGesture.Motion(zoom = 0.5f), g.update(100f, 100f, 70f)) + assertEquals(TwoFingerGesture.Mode.Zoom, g.mode) + } + @Test fun ambiguousCombinedMovementWaitsForDominance() { + val g = gesture() + assertEquals(TwoFingerGesture.Motion(), g.update(120f, 100f, 140f)) + assertEquals(TwoFingerGesture.Mode.Pending, g.mode) + g.update(150f, 100f, 142f) + assertEquals(TwoFingerGesture.Mode.Pan, g.mode) + } + @Test fun translationNeverTurnsIntoZoomMidGesture() { + val g = gesture() + g.update(120f, 100f, 100f) + assertEquals(TwoFingerGesture.Motion(1f, 2f), g.update(121f, 102f, 200f)) + assertEquals(TwoFingerGesture.Mode.Pan, g.mode) + } + @Test fun zoomNeverTurnsIntoCanvasPanMidGesture() { + val g = gesture() + g.update(100f, 100f, 140f) + assertEquals(TwoFingerGesture.Motion(), g.update(300f, 400f, 140f)) + assertEquals(TwoFingerGesture.Mode.Zoom, g.mode) + } + @Test fun outsideGraphOnlyTranslationIsAvailable() { + val g = gesture(false) + assertEquals(TwoFingerGesture.Motion(), g.update(100f, 100f, 160f)) + assertEquals(TwoFingerGesture.Mode.Pending, g.mode) + assertEquals(TwoFingerGesture.Motion(-10f, 0f), g.update(80f, 100f, 160f)) + } + @Test fun fingerLiftOrReplacementCannotResumeUntilNewGesture() { + val g = gesture() + g.update(130f, 100f, 100f) + g.end() + assertEquals(TwoFingerGesture.Motion(), g.update(300f, 100f, 200f)) + assertEquals(TwoFingerGesture.Mode.Ended, g.mode) + } + @Test fun invalidGeometryCannotPoisonFollowingEvents() { + val g = gesture() + assertEquals(TwoFingerGesture.Motion(), g.update(Float.NaN, 0f, 100f)) + assertEquals(TwoFingerGesture.Motion(10f, 0f), g.update(120f, 100f, 100f)) + } + @Test fun pageNavigationOwnershipAndCallbacksAreIndependentAndResettable() { + val a = WorkspaceTouchNavigation() + val b = WorkspaceTouchNavigation() + a.zoomAtRoot = { _, _ -> } + a.capture() + assertTrue(a.ownsTouches) + assertFalse(b.ownsTouches) + assertNull(b.zoomAtRoot) + a.reset() + assertFalse(a.ownsTouches) + assertNull(a.zoomAtRoot) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt new file mode 100644 index 0000000..e535f96 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt @@ -0,0 +1,36 @@ +package com.dct.app.tests + +import com.dct.app.data.communication.WifiSocketManager +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.net.Socket + +class WifiConnectionGenerationTest { + private fun next(manager: WifiSocketManager): Long = WifiSocketManager::class.java + .getDeclaredMethod("nextConnectionGeneration").also { it.isAccessible = true }.invoke(manager) as Long + private fun attach(manager: WifiSocketManager, socket: Socket, generation: Long): Boolean = WifiSocketManager::class.java + .getDeclaredMethod("attachSocket", Socket::class.java, String::class.java, Long::class.javaPrimitiveType) + .also { it.isAccessible = true }.invoke(manager, socket, "late", generation) as Boolean + + @Test fun lateSocketFromOlderConnectAttemptIsClosedWithoutPublishingConnection() { + val manager = WifiSocketManager() + val old = next(manager) + next(manager) + val stale = Socket() // Unconnected socket: no network activity. + assertFalse(attach(manager, stale, old)) + assertTrue(stale.isClosed) + assertFalse(manager.isConnected.value) + assertNull(manager.connectedHost.value) + } + + @Test fun disconnectInvalidatesInFlightConnectionAttempt() = runBlocking { + val manager = WifiSocketManager() + val old = next(manager) + manager.disconnectSuspend() + val stale = Socket() + assertFalse(attach(manager, stale, old)) + assertTrue(stale.isClosed) + assertFalse(manager.isConnected.value) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt new file mode 100644 index 0000000..b5acd34 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt @@ -0,0 +1,64 @@ +package com.dct.app.tests + +import android.content.SharedPreferences +import com.dct.app.data.communication.WifiConnectionInput +import com.dct.app.data.communication.WifiConnectionPreferences +import io.mockk.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WifiConnectionPreferencesTest { + private fun preferences(): SharedPreferences { + val values = mutableMapOf() + val pending = mutableMapOf() + val prefs = mockk() + val editor = mockk() + every { prefs.getString(any(), any()) } answers { values[firstArg()] ?: secondArg() } + every { prefs.edit() } returns editor + every { editor.putString(any(), any()) } answers { pending[firstArg()] = secondArg(); editor } + every { editor.apply() } answers { values.putAll(pending); pending.clear() } + return prefs + } + + @Test fun firstUseHasNoDefaultIpOrPortAndCannotConnect() { + val input = WifiConnectionPreferences(preferences()).load() + assertEquals("", input.ip) + assertEquals("", input.port) + assertFalse(input.canConnect) + assertNull(input.portNumber) + } + + @Test fun lastDraftSurvivesNewStoreAndDoesNotRequireSuccessfulConnection() { + val prefs = preferences() + val input = WifiConnectionInput("192.168.0.", "") + WifiConnectionPreferences(prefs).save(input) + assertEquals(input, WifiConnectionPreferences(prefs).load()) + val complete = WifiConnectionInput("192.168.0.15", "7777") + WifiConnectionPreferences(prefs).save(complete) + assertEquals(complete, WifiConnectionPreferences(prefs).load()) + } + + @Test fun clearingFieldsRemembersEmptyValuesInsteadOfRestoringDefaults() { + val prefs = preferences() + val store = WifiConnectionPreferences(prefs) + store.save(WifiConnectionInput("192.168.4.1", "6000")) + store.save(WifiConnectionInput()) + assertEquals(WifiConnectionInput(), WifiConnectionPreferences(prefs).load()) + } + + @Test fun invalidPortsNeverFallBackTo6000() { + for (port in listOf("", "abc", "0", "65536", "-1", "1.5", "99999999999999999")) { + val input = WifiConnectionInput("192.168.1.2", port) + assertNull(input.portNumber, port) + assertFalse(input.canConnect, port) + } + for (port in listOf("1", "6000", "65535")) assertTrue(WifiConnectionInput("192.168.1.2", port).canConnect) + } + + @Test fun ipv4ValidationAndWhitespaceHandling() { + for (ip in listOf("", "192.168.1", "256.1.2.3", "1..2.3", "1.2.3.a", "1.2.3.4.5")) { + assertFalse(WifiConnectionInput(ip, "6000").canConnect, ip) + } + assertTrue(WifiConnectionInput(" 192.168.10.22 ", " 7777 ").canConnect) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WindowActivationGestureTest.kt b/Android/app/src/test/java/com/dct/app/tests/WindowActivationGestureTest.kt new file mode 100644 index 0000000..65b8a2c --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WindowActivationGestureTest.kt @@ -0,0 +1,47 @@ +package com.dct.app.tests + +import com.dct.app.presentation.monitor.components.WindowActivationGesture +import com.dct.app.presentation.monitor.components.WorkspaceTouchNavigation +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WindowActivationGestureTest { + @Test fun firstFingerDoesNotRaiseWindowAndSingleFingerTapDoes() { + val gate = WindowActivationGesture() + assertFalse(gate.shouldActivate(false, false)) + assertTrue(gate.shouldActivate(false, true)) + } + @Test fun twoFingerPanPinchOrTapNeverActivatesEvenOnTrailingRelease() { + val gate = WindowActivationGesture() + assertFalse(gate.shouldActivate(false, false)) + assertFalse(gate.shouldActivate(true, false)) + assertFalse(gate.shouldActivate(true, true)) + assertFalse(gate.shouldActivate(false, true)) + } + @Test fun establishedSingleFingerDragActivatesOnlyOnce() { + val gate = WindowActivationGesture() + assertTrue(gate.shouldActivate(false, true)) + assertFalse(gate.shouldActivate(false, true)) + assertFalse(gate.shouldActivate(true, true)) + } + @Test fun secondWindowCannotActivateWhenItsFirstContactIsAlreadyMultiTouch() { + val gate = WindowActivationGesture() + assertFalse(gate.shouldActivate(true, false)) + assertFalse(gate.shouldActivate(false, true)) + } + @Test fun nextSingleFingerGestureCanActivateAgain() { + WindowActivationGesture().shouldActivate(true, false) + assertTrue(WindowActivationGesture().shouldActivate(false, true)) + } + @Test fun sharedMultiTouchHistorySurvivesFingerLiftAndResetsPerSequence() { + val navigation = WorkspaceTouchNavigation() + navigation.observePointerCount(1) + assertFalse(navigation.hadMultiplePointers) + navigation.observePointerCount(2) + navigation.observePointerCount(1) + navigation.observePointerCount(0) + assertTrue(navigation.hadMultiplePointers) + navigation.reset() + assertFalse(navigation.hadMultiplePointers) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WindowPageHoverTrackerTest.kt b/Android/app/src/test/java/com/dct/app/tests/WindowPageHoverTrackerTest.kt new file mode 100644 index 0000000..2e540bc --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WindowPageHoverTrackerTest.kt @@ -0,0 +1,55 @@ +package com.dct.app.tests + +import com.dct.app.presentation.mdi.WindowPageHoverTracker +import com.dct.app.presentation.mdi.windowDragOffset +import androidx.compose.ui.geometry.Offset +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WindowPageHoverTrackerTest { + @Test fun placementPreservesGrabPointWithIndependentPageScrollAndDensity() { + val pointer = Offset(160f, 70f) + val grab = Offset(40f, 10f) + val scroll = Offset(800f, 400f) + val position = windowDragOffset(pointer, grab, scroll, 2f) + assertEquals(Offset(460f, 230f), position) + assertEquals(pointer, position * 2f - scroll + grab) + assertEquals(Offset(60f, 30f), windowDragOffset(pointer, grab, Offset.Zero, 2f)) + } + + @Test fun hoveringTabPlacesHeaderAtVisibleTopOfDestination() { + assertEquals(Offset(130f, 150f), windowDragOffset( + Offset(100f, -20f), Offset(40f, 10f), Offset(200f, 300f), 2f)) + assertEquals(Offset.Zero, windowDragOffset(Offset(-10f, -20f), Offset(40f, 10f), Offset.Zero, 2f)) + } + + @Test fun activatesOnceAfterSixHundredMilliseconds() { + val tracker = WindowPageHoverTracker() + assertNull(tracker.update("page_2", 1000)) + assertNull(tracker.update("page_2", 1599)) + assertEquals("page_2", tracker.update("page_2", 1600)) + assertNull(tracker.update("page_2", 5000)) + } + + @Test fun crossingTabsOrLeavingResetsDwell() { + val tracker = WindowPageHoverTracker() + tracker.update("page_2", 0) + assertNull(tracker.update("page_3", 500)) + assertNull(tracker.update("page_3", 600)) + assertNull(tracker.update(null, 1000)) + assertNull(tracker.update("page_3", 1200)) + assertNull(tracker.update("page_3", 1799)) + assertEquals("page_3", tracker.update("page_3", 1800)) + } + + @Test fun canContinueAcrossSeveralPagesAndReturn() { + val tracker = WindowPageHoverTracker() + tracker.update("page_2", 0) + assertEquals("page_2", tracker.update("page_2", 600)) + tracker.update(null, 601) // Now hovering the active page. + tracker.update("page_3", 700) + assertEquals("page_3", tracker.update("page_3", 1300)) + tracker.update("page_1", 1400) + assertEquals("page_1", tracker.update("page_1", 2000)) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WindowParityTest.kt b/Android/app/src/test/java/com/dct/app/tests/WindowParityTest.kt new file mode 100644 index 0000000..dafafe1 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WindowParityTest.kt @@ -0,0 +1,104 @@ +package com.dct.app.tests + +import com.dct.app.domain.model.mdb.* +import com.dct.app.domain.model.project.* +import com.dct.app.domain.model.formula.FormulaDefinition +import com.dct.app.domain.model.chart.* +import com.dct.app.domain.usecase.chart.ChartBatchRenderer +import com.dct.app.data.repository.* +import com.dct.app.presentation.monitor.components.* +import com.dct.app.presentation.signaltree.* +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class WindowParityTest { + private fun signal(name: String = "RPM", idx: Int = 1) = SignalDefinition(1, idx, name, + MdbAttrType.ATTR_2_UINT16, 0.1, 0.0, "", 0x123456, 0.0, 100.0) + + @Test fun hexPreservesFullWidthAndTwosComplementAndMissingValue() { + assertEquals("0x1234", signal().formatRawHex(0x1234)) + assertEquals("0xFFFF", signal().copy(attr = MdbAttrType.ATTR_3_INT16).formatRawHex(-1)) + assertEquals("0xFFFFFFFF", signal().copy(attr = MdbAttrType.ATTR_4_UINT32).formatRawHex(0xFFFFFFFF)) + assertEquals("0x80", signal().copy(attr = MdbAttrType.ATTR_1_INT8).formatRawHex(-128)) + assertEquals("N/A", signal().formatRawHex(null)) + } + + @Test fun alphaUsesSourcePrecisionAndNcRules() { + assertEquals("12.3", AlphaSignal("sig_1_1", signal()).value(mapOf("sig_1_1" to 12.34))) + assertEquals("12.340", AlphaSignal("formula_F", signal()).value(mapOf("formula_F" to 12.34))) + val sm = AlphaSignal("sm_01_01", signal().copy(unit = "V")) + assertEquals("12.34 V", sm.value(mapOf("sm_01_01" to 12.34))) + for (v in listOf(Double.NaN, Double.POSITIVE_INFINITY, -999999.0)) assertEquals("NC", sm.value(mapOf(sm.id to v))) + assertEquals("NC", sm.value(emptyMap())) + assertEquals("N/A", AlphaSignal("sig_1_1", signal()).value(emptyMap())) + } + + @Test fun a2lIsNotMistakenForCanAndRespectsFormat() { + val sig = signal().copy(adl = 0x1234, messageName = "A2L", messageId = 0x1234, measurementFormat = "%6.2f") + val entry = resolveAlphaSignals(listOf("a2l_4660"), emptyList(), emptyList(), listOf(sig), emptyList(), emptyList()).single() + assertEquals("a2l_4660", entry.id) + assertEquals("12.35", entry.value(mapOf(entry.id to 12.345, "can_1" to 90.0))) + assertEquals("1.234", sig.copy(measurementFormat = "%999999999f").formatMeasurementValue(1.234)) + } + + @Test fun alphaMenuAndBodyShareSensorAndFormulaNamesAndColors() { + val mod = DpcSensorModuleConfig(0, channels = listOf(DpcSensorModuleChannel(0, "Ambient", color = 0x0011FF))) + val formula = FormulaDefinition(name = "F", expression = "1+2", color = 0x0033FF) + val entries = resolveAlphaSignals(listOf("sm_01_01", "formula_F"), emptyList(), emptyList(), emptyList(), listOf(formula), listOf(mod)) + assertEquals(listOf("sm_01_01", "formula_F"), entries.map { it.id }) + assertEquals(listOf("Ambient", "F"), entries.map { it.definition.name }) + assertEquals(listOf(0x0011FFL, 0x0033FFL), entries.map { it.definition.color }) + val renamed = mod.copy(channels = listOf(mod.channels.single().copy(name = "Outside"))) + assertEquals("sm_01_01", resolveAlphaSignals(listOf("sm_01_01"), emptyList(), emptyList(), emptyList(), emptyList(), listOf(renamed)).single().id) + } + + @Test fun canSortsNumericValuesNumericallyAndUnknownValuesLast() { + val signals = listOf(signal("B", 2), signal("A", 1), signal("C", 3)) + assertEquals(listOf(1, 2, 3), sortCanSignals(signals, "name", false, emptyMap()).map { it.idx }) + val values = mapOf("can_1" to 10.0, "can_2" to 2.0) + assertEquals(listOf(2, 1, 3), sortCanSignals(signals, "value", false, values).map { it.idx }) + assertEquals(listOf(1, 2, 3), sortCanSignals(signals, "value", true, values).map { it.idx }) + assertEquals(signals, sortCanSignals(signals, "", false, values)) + } + + @Test fun windowStateAndLegendNoneRoundTripWithoutAffectingOtherWindows() { + val prefs = mapOf("search" to "온도", "expanded" to "true", "width:name" to "247.5", + "checked_addresses" to "1,4294967295", "sort_column" to "msgId", "sort_descending" to "true") + val windows = listOf(DpcWindowState("one", "CAN_SIGNAL_LIST", "CAN", contentState = prefs, chart = DpcChartState(legendPosition = 3)), + DpcWindowState("two", "CAN_SIGNAL_LIST", "CAN2")) + val workspace = DpcWorkspaceState("p", listOf(DpcPageState("p", "Page", windows))) + val restored = DpcWorkspaceJson.decode(DpcWorkspaceJson.encode(workspace))!! + assertEquals(prefs, restored.pages.single().windows[0].contentState) + assertTrue(restored.pages.single().windows[1].contentState.isEmpty()) + assertEquals(3, restored.pages.single().windows[0].chart!!.legendPosition) + } + + @Test fun windowStoreCommitsImmediatelyAndClampsColumnWidth() { + var saved = emptyMap() + val store = WindowUiStore(emptyMap()) { saved = it } + val widths = WindowColumnWidths(store) + widths["name"] = androidx.compose.ui.unit.Dp(200f) + assertEquals("200.0", saved["width:name"]) + widths["name"] = androidx.compose.ui.unit.Dp(-2f) + assertEquals(32f, widths["name"]!!.value) + widths.remove("name") + assertFalse(saved.containsKey("width:name")) + } + + @Test fun descriptionsRoundTripInProjectExtension() { + val project = DpcProject(parameterDescriptions = mapOf("constant:K" to "설명", "array:A" to "")) + val restored = DpcProjectManager.parseProjectFromJson(DpcProjectManager.serializeProjectToJson(project), null) + assertEquals(project.parameterDescriptions, restored.parameterDescriptions) + } + + @Test fun stoppedGraphCursorUpdatesSnapshotImmediately() { + val renderer = ChartBatchRenderer(TestDispatcherProvider(UnconfinedTestDispatcher())) + renderer.stop() + renderer.setCrosshairTimeSec(1.5) + assertEquals(1.5, renderer.renderSnapshotFlow.value.crosshairTimeSec) + renderer.setCrosshairTimeSec(null) + assertNull(renderer.renderSnapshotFlow.value.crosshairTimeSec) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt b/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt new file mode 100644 index 0000000..e4f40db --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt @@ -0,0 +1,136 @@ +package com.dct.app.tests + +import com.dct.app.core.engine.XcpDaqStreamDecoder +import com.dct.app.data.parser.A2lParser +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream + +class XcpEngineTest { + + @Test + fun testA2lDataTypeUnpacking() { + // 1. UBYTE (1B Unsigned) + val ubyteBytes = byteArrayOf(0xFE.toByte()) + assertEquals(254.0, A2lDataType.UBYTE.unpackRawValue(ubyteBytes, 0, false), 0.001) + + // 2. SBYTE (1B Signed) + val sbyteBytes = byteArrayOf(0xFE.toByte()) // -2 in 2's complement + assertEquals(-2.0, A2lDataType.SBYTE.unpackRawValue(sbyteBytes, 0, false), 0.001) + + // 3. UWORD (2B Unsigned Little-Endian vs Big-Endian) + val uwordBytes = byteArrayOf(0x34, 0x12) // 0x1234 = 4660 + assertEquals(4660.0, A2lDataType.UWORD.unpackRawValue(uwordBytes, 0, isMsbFirst = false), 0.001) + assertEquals(0x3412.toDouble(), A2lDataType.UWORD.unpackRawValue(uwordBytes, 0, isMsbFirst = true), 0.001) + + // 4. SWORD (2B Signed Little-Endian) + val swordBytes = byteArrayOf(0xFE.toByte(), 0xFF.toByte()) // -2 in 16-bit + assertEquals(-2.0, A2lDataType.SWORD.unpackRawValue(swordBytes, 0, isMsbFirst = false), 0.001) + + // 5. ULONG (4B Unsigned Little-Endian) + val ulongBytes = byteArrayOf(0x78, 0x56, 0x34, 0x12) // 0x12345678 = 305419896 + assertEquals(305419896.0, A2lDataType.ULONG.unpackRawValue(ulongBytes, 0, isMsbFirst = false), 0.001) + + // 6. FLOAT32_IEEE (4B Float Little-Endian) + val floatStream = ByteArrayOutputStream() + val floatBits = java.lang.Float.floatToIntBits(123.456f) + floatStream.write(floatBits and 0xFF) + floatStream.write((floatBits shr 8) and 0xFF) + floatStream.write((floatBits shr 16) and 0xFF) + floatStream.write((floatBits shr 24) and 0xFF) + val floatBytes = floatStream.toByteArray() + assertEquals(123.456, A2lDataType.FLOAT32_IEEE.unpackRawValue(floatBytes, 0, isMsbFirst = false), 0.001) + } + + @Test + fun testXcpDaqStreamDecoder() { + val decoder = XcpDaqStreamDecoder() + + val speedSig = SignalDefinition( + reclid = 0, + idx = 0, + name = "Engine_Speed", + attr = MdbAttrType.ATTR_2_UINT16, + cvsA = 0.25, + cvsB = 0.0, + unit = "rpm", + color = 0xFF0000, + min = 0.0, + max = 8000.0, + adl = 0x1000 + ) + + val throttleSig = SignalDefinition( + reclid = 0, + idx = 1, + name = "Throttle_Pos", + attr = MdbAttrType.ATTR_0_UINT8, + cvsA = 0.5, + cvsB = 0.0, + unit = "%", + color = 0x00FF00, + min = 0.0, + max = 100.0, + adl = 0x1002 + ) + + val rawMeasurements = listOf( + A2lParser.A2lMeasurement( + name = "Engine_Speed", + dataType = "UWORD", + a2lDataType = A2lDataType.UWORD, + conversionMethod = "SpeedMethod", + ecuAddress = 0x1000L + ), + A2lParser.A2lMeasurement( + name = "Throttle_Pos", + dataType = "UBYTE", + a2lDataType = A2lDataType.UBYTE, + conversionMethod = "ThrottleMethod", + ecuAddress = 0x1002L + ) + ) + + val compuMethods = mapOf( + "speedmethod" to A2lParser.CompuMethod("SpeedMethod", conversionType = "LINEAR", cvsA = 0.25, cvsB = 0.0), + "throttlemethod" to A2lParser.CompuMethod("ThrottleMethod", conversionType = "LINEAR", cvsA = 0.5, cvsB = 0.0) + ) + + decoder.configureDaqSignals( + signals = listOf(speedSig, throttleSig), + rawMeasurements = rawMeasurements, + compuMethods = compuMethods, + isMsbFirst = false + ) + + assertTrue(decoder.isConfigured()) + + // PC D0 payload: count(2B BE) + [type + value] 반복 + val payload = ByteArrayOutputStream().apply { + write(0x00) + write(0x02) + // Speed = 4000 = 0x0FA0 (Little-Endian: 0xA0, 0x0F) + write(A2lDataType.UWORD.code) + write(0xA0) + write(0x0F) + // Throttle = 150 = 0x96 + write(A2lDataType.UBYTE.code) + write(0x96) + }.toByteArray() + + val decoded = decoder.decodeDaqPayload(payload) + assertEquals(1000.0, decoded["Engine_Speed"] ?: 0.0, 0.001) + assertEquals(75.0, decoded["Throttle_Pos"] ?: 0.0, 0.001) + assertEquals(1000.0, decoded["a2l_4096"] ?: 0.0, 0.001) // 0x1000 = 4096 + + assertTrue(decoder.decodeDaqPayload(byteArrayOf(0x00, 0x02, 0x02)).isEmpty()) + val wrongType = byteArrayOf(0x00, 0x02, 0x00, 0x01, 0x00, 0x02) + assertTrue(decoder.decodeDaqPayload(wrongType).isEmpty()) + } + +} diff --git a/Android/build.gradle.kts b/Android/build.gradle.kts new file mode 100644 index 0000000..059b433 --- /dev/null +++ b/Android/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("com.android.application") version "9.3.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.10" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false +} diff --git a/Android/gradle.properties b/Android/gradle.properties new file mode 100644 index 0000000..bb6e9ee --- /dev/null +++ b/Android/gradle.properties @@ -0,0 +1,28 @@ +# Gradle JVM and Memory Configuration +org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8 -XX:+UseG1GC +org.gradle.java.home=C:\\Program Files\\Android\\openjdk\\jdk-21.0.8 + +# AndroidX and Kotlin Configuration +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +kotlin.compiler.execution.strategy=in-process + +# Build Optimization +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=false +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/Android/gradle/wrapper/gradle-wrapper.jar b/Android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7f93135 Binary files /dev/null and b/Android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Android/gradle/wrapper/gradle-wrapper.properties b/Android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1a70468 --- /dev/null +++ b/Android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Android/gradlew b/Android/gradlew new file mode 100644 index 0000000..1aa94a4 --- /dev/null +++ b/Android/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Android/gradlew.bat b/Android/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/Android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/Android/launch_test.ps1 b/Android/launch_test.ps1 new file mode 100644 index 0000000..131220d --- /dev/null +++ b/Android/launch_test.ps1 @@ -0,0 +1,10 @@ +$env:JAVA_HOME = "C:\Program Files\Android\openjdk\jdk-21.0.8" +$env:Path = "$env:JAVA_HOME\bin;" + $env:Path +Set-Location "C:\Users\COMPUTER1\Desktop\mobi\DCT\Android" + +Remove-Item -Path "C:\Users\COMPUTER1\Desktop\mobi\DCT\test_done.txt" -Force -ErrorAction SilentlyContinue +Remove-Item -Path "C:\Users\COMPUTER1\Desktop\mobi\DCT\test_result.log" -Force -ErrorAction SilentlyContinue + +$proc = Start-Process -FilePath "cmd.exe" -ArgumentList "/c call gradlew.bat testDebugUnitTest > C:\Users\COMPUTER1\Desktop\mobi\DCT\test_result.log 2>&1 & echo DONE! > C:\Users\COMPUTER1\Desktop\mobi\DCT\test_done.txt" -WorkingDirectory "C:\Users\COMPUTER1\Desktop\mobi\DCT\Android" -WindowStyle Hidden -PassThru + +Write-Output "STARTED PID $($proc.Id)" diff --git a/Android/run_tests.ps1 b/Android/run_tests.ps1 new file mode 100644 index 0000000..b9b43f3 --- /dev/null +++ b/Android/run_tests.ps1 @@ -0,0 +1,8 @@ +$env:JAVA_HOME = "C:\Program Files\Android\openjdk\jdk-21.0.8" +$env:Path = "$env:JAVA_HOME\bin;" + $env:Path +Set-Location "C:\Users\COMPUTER1\Desktop\mobi\DCT\Android" +Stop-Process -Name java -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 1 +Remove-Item -Recurse -Force "app\build\test-results" -ErrorAction SilentlyContinue +cmd.exe /c "gradlew.bat --no-daemon testDebugUnitTest > test_output.txt 2>&1" +Get-Content "test_output.txt" -Tail 50 diff --git a/Android/scripts/verify-local.ps1 b/Android/scripts/verify-local.ps1 new file mode 100644 index 0000000..26498f7 --- /dev/null +++ b/Android/scripts/verify-local.ps1 @@ -0,0 +1,51 @@ +param([string[]]$TestClasses = @(), [switch]$SkipCompile) +$ErrorActionPreference = 'Stop' +$taskRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$taskBuild = Join-Path $taskRoot 'app/build/workspace-verification' +$taskJava = 'C:/Program Files/Android/Android Studio/jbr/bin/java.exe' +$taskCompiler = 'C:/Program Files/Android/Android Studio/plugins/Kotlin/kotlinc/lib/*' +$taskJars = @(Get-ChildItem 'C:/Users/COMPUTER1/.gradle/caches' -Filter '*.jar' -Recurse | ForEach-Object { $_.FullName.Replace('\', '/') }) +$taskHamcrest = Join-Path $taskBuild 'hamcrest-core-1.3.jar' +function Resolve-Classpath($lines) { + @($lines | ForEach-Object { + if ($_ -match '\.jar;') { + $fixed = @($_.Trim('"').Split(';') | ForEach-Object { + $path = $_ + if (Test-Path -LiteralPath $path) { $path } + else { + $suffix = if ($path -match '/transformed/(.+)$') { $Matches[1] } else { [System.IO.Path]::GetFileName($path) } + $replacement = $taskJars | Where-Object { $_.EndsWith('/' + $suffix) } | Select-Object -First 1 + if (!$replacement -and $path -match '/files-2.1/([^/]+)/([^/]+)/') { + $modulePath = '/files-2.1/' + $Matches[1] + '/' + $Matches[2] + '/' + $replacement = $taskJars | Where-Object { $_.Contains($modulePath) } | Sort-Object -Descending | Select-Object -First 1 + } + if ($replacement) { $replacement } + else { Write-Warning "Obsolete cached dependency omitted: $path" } + } + }) + if (Test-Path -LiteralPath $taskHamcrest) { $fixed += $taskHamcrest.Replace('\', '/') } + '"' + (($fixed | Select-Object -Unique) -join ';') + '"' + } else { $_ } + }) +} +function Compile-Sources($template, $name, $sourceRoot) { + $argsLines = @(Resolve-Classpath @(Get-Content (Join-Path $taskBuild $template) | Where-Object { $_ -notmatch '^".*\.(kt|java)"$' })) + $sources = @(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.kt' -Recurse | ForEach-Object { '"' + $_.FullName.Replace('\', '/') + '"' }) + if ($name -eq 'test-current.args') { $sources += '"' + (Join-Path $taskBuild 'RunWorkspaceTests.kt').Replace('\', '/') + '"' } + $file = Join-Path $taskBuild $name + [System.IO.File]::WriteAllLines($file, ($argsLines + $sources)) + & $taskJava '-cp' $taskCompiler 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler' ('@' + $file) 2>&1 | Tee-Object (Join-Path $taskBuild ($name + '.log')) | Select-Object -Last 55 + if ($LASTEXITCODE -ne 0) { throw "Compilation failed: $name" } +} +if (!$SkipCompile) { + Compile-Sources 'compile-main.args' 'main-current.args' (Join-Path $taskRoot 'app/src/main/java') + Compile-Sources 'test.args' 'test-current.args' (Join-Path $taskRoot 'app/src/test/java') +} +$runLines = @(Resolve-Classpath @(Get-Content (Join-Path $taskBuild 'run-tests.args'))) +$runnerLine = [Array]::IndexOf($runLines, '"RunWorkspaceTestsKt"') +if ($runnerLine -lt 0) { throw 'JUnit launcher argument not found' } +$selected = @($TestClasses | ForEach-Object { '"' + $_ + '"' }) +$runFile = Join-Path $taskBuild 'run-current.args' +[System.IO.File]::WriteAllLines($runFile, @($runLines[0..$runnerLine]) + $selected) +Push-Location $taskRoot +try { & $taskJava '-Dfile.encoding=UTF-8' ('@' + $runFile) 2>&1 | Tee-Object (Join-Path $taskBuild 'test-current.log') | Select-Object -Last 65; exit $LASTEXITCODE } finally { Pop-Location } diff --git a/Android/scripts/verify-ui-local.ps1 b/Android/scripts/verify-ui-local.ps1 new file mode 100644 index 0000000..5784cfd --- /dev/null +++ b/Android/scripts/verify-ui-local.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference = 'Stop' +$taskRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$taskBuild = Join-Path $taskRoot 'app/build/workspace-verification' +$taskJava = 'C:/Program Files/Android/Android Studio/jbr/bin/java.exe' +$taskKotlin = 'C:/Program Files/Android/Android Studio/plugins/Kotlin/kotlinc/lib' +$taskArgs = @(Get-Content (Join-Path $taskBuild 'test-current.args') | Where-Object { $_ -notmatch '^".*\.(kt|java)"$' } | ForEach-Object { $_.Trim('"') }) +$taskJars = @(Get-ChildItem 'C:/Users/COMPUTER1/.gradle/caches' -Filter 'classes.jar' -Recurse | Where-Object { + $_.FullName -match 'transformed[\\/](ui-test-release|ui-test-junit4-release|monitor-1.6.1|espresso-core-3.5.1|runner-1.5.2)[\\/]' +} | Group-Object { $_.Directory.Parent.Name } | ForEach-Object { $_.Group[0].FullName.Replace('\', '/') }) +$taskClasspathIndex = [Array]::IndexOf($taskArgs, '-classpath') + 1 +if ($taskClasspathIndex -le 0 -or $taskJars.Count -lt 5) { throw 'UI test compiler dependencies unavailable' } +$taskArgs[$taskClasspathIndex] += ';' + ($taskJars -join ';') +$taskOutputIndex = [Array]::IndexOf($taskArgs, '-d') + 1 +if ($taskOutputIndex -le 0) { throw 'Missing Kotlin output argument' } +$taskArgs[$taskOutputIndex] = (Join-Path $taskBuild 'androidTest-ui').Replace('\', '/') +$taskArgs += '-Xplugin=' + $taskKotlin + '/compose-compiler-plugin.jar' +$taskArgs += @(Get-ChildItem (Join-Path $taskRoot 'app/src/androidTest/java') -Filter '*.kt' -Recurse | ForEach-Object { $_.FullName.Replace('\', '/') }) +$taskArgsFile = Join-Path $taskBuild 'ui-current.args' +[System.IO.File]::WriteAllLines($taskArgsFile, @($taskArgs | ForEach-Object { '"' + $_ + '"' })) +& $taskJava '-cp' ($taskKotlin + '/*') 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler' ('@' + $taskArgsFile) 2>&1 | Tee-Object (Join-Path $taskBuild 'ui-current.log') +exit $LASTEXITCODE diff --git a/Android/settings.gradle.kts b/Android/settings.gradle.kts new file mode 100644 index 0000000..800e591 --- /dev/null +++ b/Android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "DCT" +include(":app") diff --git a/DCT_test b/DCT_test new file mode 160000 index 0000000..74d2da3 --- /dev/null +++ b/DCT_test @@ -0,0 +1 @@ +Subproject commit 74d2da3be96ca194382d4eb0eddf6145933a4587