From e1beed623d46243a2730a6d15611d545642bf980 Mon Sep 17 00:00:00 2001 From: seuuung Date: Mon, 7 Sep 2026 10:54:01 +0900 Subject: [PATCH] DCT --- .gitignore | 85 + Android/.gitignore | 18 + Android/DCT Work/Configuration/AUTO_TEST.dpc | 678 ++++ Android/DCT Work/Configuration/Sample.mdb.dpc | 786 +++++ .../Data/PROJECT_2026-09-02_11-37-04_sm.csv | 1 + .../Data/PROJECT_2026-09-02_11-37-26_sm.csv | 1 + .../Data/PROJECT_2026-09-02_11-40-16_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-50-24_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-51-12_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-53-02_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-54-20_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-57-34_sm.csv | 1 + .../Data/PROJECT_2026-09-02_16-00-50_sm.csv | 1 + .../Data/PROJECT_2026-09-02_16-33-18_sm.csv | 1 + .../Data/PROJECT_2026-09-02_16-40-01_sm.csv | 1 + .../Data/PROJECT_2026-09-02_16-41-17_sm.csv | 1 + .../Data/PROJECT_2026-09-02_16-52-25_sm.csv | 1 + .../Data/PROJECT_2026-09-02_22-38-04_sm.csv | 1 + .../Data/PROJECT_2026-09-02_22-40-53_sm.csv | 1 + .../Data/PROJECT_2026-09-02_22-45-02_sm.csv | 1 + .../Data/PROJECT_2026-09-03_09-41-56_sm.csv | 1 + .../Data/PROJECT_2026-09-03_09-45-18_sm.csv | 1 + .../Data/PROJECT_2026-09-03_10-03-50_sm.csv | 1 + .../Data/PROJECT_2026-09-03_10-14-02_sm.csv | 1 + .../Data/PROJECT_2026-09-03_10-16-42_sm.csv | 1 + .../Data/PROJECT_2026-09-03_10-28-30_sm.csv | 1 + .../Data/PROJECT_2026-09-03_10-37-10_sm.csv | 1 + .../Data/PROJECT_2026-09-03_10-40-28_sm.csv | 1 + .../Data/PROJECT_2026-09-03_10-50-58_sm.csv | 1 + .../Data/PROJECT_2026-09-03_10-52-47_sm.csv | 1 + .../Data/PROJECT_2026-09-03_11-42-05_sm.csv | 1 + .../Data/PROJECT_2026-09-03_11-44-21_sm.csv | 1 + .../Data/PROJECT_2026-09-03_11-55-33_sm.csv | 1 + .../Data/PROJECT_2026-09-03_11-57-04_sm.csv | 1 + .../Data/PROJECT_2026-09-03_14-07-08_sm.csv | 1 + .../Data/PROJECT_2026-09-03_14-17-04_sm.csv | 1 + .../Data/PROJECT_2026-09-03_14-21-44_sm.csv | 1 + .../Data/PROJECT_2026-09-03_14-29-41_sm.csv | 1 + .../Data/PROJECT_2026-09-03_14-30-37_sm.csv | 1 + .../Data/PROJECT_2026-09-03_14-43-35_sm.csv | 1 + .../PROJECT_2026-09-03_14-55-48-594_sm.csv | 1 + .../PROJECT_2026-09-03_15-00-38-186_sm.csv | 1 + .../PROJECT_2026-09-03_15-02-33-023_sm.csv | 1 + .../PROJECT_2026-09-03_15-24-29-617_sm.csv | 1 + .../PROJECT_2026-09-03_15-35-27-968_sm.csv | 1 + .../PROJECT_2026-09-03_15-41-40-020_sm.csv | 1 + .../PROJECT_2026-09-03_15-43-47-387_sm.csv | 1 + .../PROJECT_2026-09-03_15-45-38-906_sm.csv | 1 + .../PROJECT_2026-09-03_15-52-50-791_sm.csv | 1 + .../PROJECT_2026-09-03_16-07-21-559_sm.csv | 1 + .../PROJECT_2026-09-03_16-09-22-691_sm.csv | 1 + .../Data/Sample_2026-09-02_11-37-03_mdb.csv | 1 + .../Data/Sample_2026-09-02_11-37-03_sm.csv | 1 + .../Data/Sample_2026-09-02_11-37-04_mdb.csv | 1 + .../Data/Sample_2026-09-02_11-37-04_sm.csv | 1 + .../Data/Sample_2026-09-02_11-37-26_mdb.csv | 1 + .../Data/Sample_2026-09-02_11-37-26_sm.csv | 1 + .../Data/Sample_2026-09-02_11-40-16_mdb.csv | 1 + .../Data/Sample_2026-09-02_11-40-16_sm.csv | 1 + .../Data/Sample_2026-09-02_15-50-24_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-50-24_sm.csv | 1 + .../Data/Sample_2026-09-02_15-51-12_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-51-12_sm.csv | 1 + .../Data/Sample_2026-09-02_15-53-02_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-53-02_sm.csv | 1 + .../Data/Sample_2026-09-02_15-54-20_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-54-20_sm.csv | 1 + .../Data/Sample_2026-09-02_15-57-33_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-57-33_sm.csv | 1 + .../Data/Sample_2026-09-02_15-57-34_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-57-34_sm.csv | 1 + .../Data/Sample_2026-09-02_16-00-50_mdb.csv | 1 + .../Data/Sample_2026-09-02_16-00-50_sm.csv | 1 + .../Data/Sample_2026-09-02_16-33-18_mdb.csv | 1 + .../Data/Sample_2026-09-02_16-33-18_sm.csv | 1 + .../Data/Sample_2026-09-02_16-40-00_mdb.csv | 1 + .../Data/Sample_2026-09-02_16-40-00_sm.csv | 1 + .../Data/Sample_2026-09-02_16-40-01_mdb.csv | 1 + .../Data/Sample_2026-09-02_16-40-01_sm.csv | 1 + .../Data/Sample_2026-09-02_16-41-17_mdb.csv | 1 + .../Data/Sample_2026-09-02_16-41-17_sm.csv | 1 + .../Data/Sample_2026-09-02_16-52-25_mdb.csv | 1 + .../Data/Sample_2026-09-02_16-52-25_sm.csv | 1 + .../Data/Sample_2026-09-02_22-38-04_mdb.csv | 1 + .../Data/Sample_2026-09-02_22-38-04_sm.csv | 1 + .../Data/Sample_2026-09-02_22-40-53_mdb.csv | 1 + .../Data/Sample_2026-09-02_22-40-53_sm.csv | 1 + .../Data/Sample_2026-09-02_22-45-02_mdb.csv | 1 + .../Data/Sample_2026-09-02_22-45-02_sm.csv | 1 + .../Data/Sample_2026-09-03_09-38-51_mdb.csv | 1 + .../Data/Sample_2026-09-03_09-38-51_sm.csv | 1 + .../Data/Sample_2026-09-03_09-41-56_mdb.csv | 1 + .../Data/Sample_2026-09-03_09-41-56_sm.csv | 1 + .../Data/Sample_2026-09-03_09-45-18_mdb.csv | 1 + .../Data/Sample_2026-09-03_09-45-18_sm.csv | 1 + .../Data/Sample_2026-09-03_10-03-49_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-03-49_sm.csv | 1 + .../Data/Sample_2026-09-03_10-03-50_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-03-50_sm.csv | 1 + .../Data/Sample_2026-09-03_10-14-02_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-14-02_sm.csv | 1 + .../Data/Sample_2026-09-03_10-14-03_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-14-03_sm.csv | 1 + .../Data/Sample_2026-09-03_10-16-42_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-16-42_sm.csv | 1 + .../Data/Sample_2026-09-03_10-28-30_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-28-30_sm.csv | 1 + .../Data/Sample_2026-09-03_10-37-10_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-37-10_sm.csv | 1 + .../Data/Sample_2026-09-03_10-40-28_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-40-28_sm.csv | 1 + .../Data/Sample_2026-09-03_10-50-58_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-50-58_sm.csv | 1 + .../Data/Sample_2026-09-03_10-52-47_mdb.csv | 1 + .../Data/Sample_2026-09-03_10-52-47_sm.csv | 1 + .../Data/Sample_2026-09-03_11-42-05_mdb.csv | 1 + .../Data/Sample_2026-09-03_11-42-05_sm.csv | 1 + .../Data/Sample_2026-09-03_11-44-21_mdb.csv | 1 + .../Data/Sample_2026-09-03_11-44-21_sm.csv | 1 + .../Data/Sample_2026-09-03_11-55-33_mdb.csv | 1 + .../Data/Sample_2026-09-03_11-55-33_sm.csv | 1 + .../Data/Sample_2026-09-03_11-57-04_mdb.csv | 1 + .../Data/Sample_2026-09-03_11-57-04_sm.csv | 1 + .../Data/Sample_2026-09-03_14-07-08_mdb.csv | 1 + .../Data/Sample_2026-09-03_14-07-08_sm.csv | 1 + .../Data/Sample_2026-09-03_14-17-04_mdb.csv | 1 + .../Data/Sample_2026-09-03_14-17-04_sm.csv | 1 + .../Data/Sample_2026-09-03_14-21-44_mdb.csv | 1 + .../Data/Sample_2026-09-03_14-21-44_sm.csv | 1 + .../Data/Sample_2026-09-03_14-29-41_mdb.csv | 1 + .../Data/Sample_2026-09-03_14-29-41_sm.csv | 1 + .../Data/Sample_2026-09-03_14-30-37_mdb.csv | 1 + .../Data/Sample_2026-09-03_14-30-37_sm.csv | 1 + .../Data/Sample_2026-09-03_14-43-35_mdb.csv | 1 + .../Data/Sample_2026-09-03_14-43-35_sm.csv | 1 + .../Sample_2026-09-03_14-55-48-378_mdb.csv | 1 + .../Sample_2026-09-03_14-55-48-378_sm.csv | 1 + .../Sample_2026-09-03_14-55-48-638_mdb.csv | 1 + .../Sample_2026-09-03_14-55-48-638_sm.csv | 1 + .../Sample_2026-09-03_15-00-37-948_mdb.csv | 1 + .../Sample_2026-09-03_15-00-37-948_sm.csv | 1 + .../Sample_2026-09-03_15-00-38-235_mdb.csv | 1 + .../Sample_2026-09-03_15-00-38-235_sm.csv | 1 + .../Sample_2026-09-03_15-02-32-817_mdb.csv | 1 + .../Sample_2026-09-03_15-02-32-817_sm.csv | 1 + .../Sample_2026-09-03_15-02-33-060_mdb.csv | 1 + .../Sample_2026-09-03_15-02-33-060_sm.csv | 1 + .../Sample_2026-09-03_15-24-29-390_mdb.csv | 1 + .../Sample_2026-09-03_15-24-29-390_sm.csv | 1 + .../Sample_2026-09-03_15-24-29-660_mdb.csv | 1 + .../Sample_2026-09-03_15-24-29-660_sm.csv | 1 + .../Sample_2026-09-03_15-35-27-744_mdb.csv | 1 + .../Sample_2026-09-03_15-35-27-744_sm.csv | 1 + .../Sample_2026-09-03_15-35-28-014_mdb.csv | 1 + .../Sample_2026-09-03_15-35-28-014_sm.csv | 1 + .../Sample_2026-09-03_15-41-39-794_mdb.csv | 1 + .../Sample_2026-09-03_15-41-39-794_sm.csv | 1 + .../Sample_2026-09-03_15-41-40-065_mdb.csv | 1 + .../Sample_2026-09-03_15-41-40-065_sm.csv | 1 + .../Sample_2026-09-03_15-43-47-160_mdb.csv | 1 + .../Sample_2026-09-03_15-43-47-160_sm.csv | 1 + .../Sample_2026-09-03_15-43-47-433_mdb.csv | 1 + .../Sample_2026-09-03_15-43-47-433_sm.csv | 1 + .../Sample_2026-09-03_15-45-38-692_mdb.csv | 1 + .../Sample_2026-09-03_15-45-38-692_sm.csv | 1 + .../Sample_2026-09-03_15-45-38-958_mdb.csv | 1 + .../Sample_2026-09-03_15-45-38-958_sm.csv | 1 + .../Sample_2026-09-03_15-52-50-552_mdb.csv | 1 + .../Sample_2026-09-03_15-52-50-552_sm.csv | 1 + .../Sample_2026-09-03_15-52-50-848_mdb.csv | 1 + .../Sample_2026-09-03_15-52-50-848_sm.csv | 1 + .../Sample_2026-09-03_16-07-21-345_mdb.csv | 1 + .../Sample_2026-09-03_16-07-21-345_sm.csv | 1 + .../Sample_2026-09-03_16-07-21-603_mdb.csv | 1 + .../Sample_2026-09-03_16-07-21-603_sm.csv | 1 + .../Sample_2026-09-03_16-09-22-454_mdb.csv | 1 + .../Sample_2026-09-03_16-09-22-454_sm.csv | 1 + .../Sample_2026-09-03_16-09-22-738_mdb.csv | 1 + .../Sample_2026-09-03_16-09-22-738_sm.csv | 1 + Android/app/.gitignore | 1 + Android/app/DCT Work/AUTO_TEST.dpc | 658 ++++ .../app/DCT Work/Configuration/AUTO_TEST.dpc | 678 ++++ .../app/DCT Work/Configuration/Sample.mdb.dpc | 786 +++++ .../Data/DCT_MEAS_20260825_112815.csv | 2 + .../Data/DCT_MEAS_20260825_113103.csv | 2 + .../Data/DCT_MEAS_20260825_125234.csv | 2 + .../Data/DCT_MEAS_20260825_130759.csv | 2 + .../Data/DCT_MEAS_20260825_131946.csv | 2 + .../Data/DCT_MEAS_20260825_132323.csv | 2 + .../Data/DCT_MEAS_20260825_132750.csv | 2 + .../Data/DCT_MEAS_20260825_133030.csv | 2 + .../Data/DCT_MEAS_20260825_133540.csv | 2 + .../Data/DCT_MEAS_20260825_133541.csv | 2 + .../Data/DCT_MEAS_20260825_133902.csv | 2 + .../Data/DCT_MEAS_20260825_134652.csv | 2 + .../Data/DCT_MEAS_20260825_134908.csv | 2 + .../Data/DCT_MEAS_20260825_135149.csv | 2 + .../Data/DCT_MEAS_20260825_135542.csv | 2 + .../Data/DCT_MEAS_20260825_135806.csv | 2 + .../Data/DCT_MEAS_20260825_140830.csv | 2 + .../Data/DCT_MEAS_20260825_141011.csv | 2 + .../Data/DCT_MEAS_20260825_141809.csv | 2 + .../Data/DCT_MEAS_20260825_142203.csv | 2 + .../Data/DCT_MEAS_20260825_142429.csv | 2 + .../Data/DCT_MEAS_20260825_142832.csv | 2 + .../Data/DCT_MEAS_20260825_143145.csv | 2 + .../Data/DCT_MEAS_20260825_144157.csv | 2 + .../Data/DCT_MEAS_20260825_145628.csv | 2 + .../Data/DCT_MEAS_20260825_145757.csv | 2 + .../Data/DCT_MEAS_20260825_150926.csv | 2 + .../Data/DCT_MEAS_20260825_151422.csv | 2 + .../Data/DCT_MEAS_20260825_151832.csv | 2 + .../Data/DCT_MEAS_20260825_160435.csv | 2 + .../Data/DCT_MEAS_20260825_164514.csv | 2 + .../Data/DCT_MEAS_20260825_164814.csv | 2 + .../Data/DCT_MEAS_20260825_165257.csv | 2 + .../Data/DCT_MEAS_20260825_165743.csv | 2 + .../Data/DCT_MEAS_20260825_170527.csv | 2 + .../Data/DCT_MEAS_20260825_170528.csv | 2 + .../Data/DCT_MEAS_20260825_170947.csv | 2 + .../Data/DCT_MEAS_20260826_091323.csv | 2 + .../Data/DCT_MEAS_20260826_091939.csv | 2 + .../Data/DCT_MEAS_20260826_100240.csv | 2 + .../Data/DCT_MEAS_20260826_100519.csv | 2 + .../Data/DCT_MEAS_20260826_100941.csv | 2 + .../Data/DCT_MEAS_20260826_101242.csv | 2 + .../Data/DCT_MEAS_20260826_102051.csv | 2 + .../Data/DCT_MEAS_20260826_102414.csv | 2 + .../Data/DCT_MEAS_20260826_102713.csv | 2 + .../Data/DCT_MEAS_20260826_103828.csv | 2 + .../Data/DCT_MEAS_20260826_104635.csv | 2 + .../Data/DCT_MEAS_20260826_104636.csv | 2 + .../Data/DCT_MEAS_20260826_105358.csv | 2 + .../Data/DCT_MEAS_20260826_105359.csv | 2 + .../Data/DCT_MEAS_20260826_105503.csv | 2 + .../Data/DCT_MEAS_20260826_110114.csv | 2 + .../Data/DCT_MEAS_20260826_110718.csv | 2 + .../Data/DCT_MEAS_20260826_111359.csv | 2 + .../Data/DCT_MEAS_20260826_112319.csv | 2 + .../Data/DCT_MEAS_20260826_113309.csv | 2 + .../Data/DCT_MEAS_20260826_113350.csv | 2 + .../Data/DCT_MEAS_20260826_121417.csv | 2 + .../Data/DCT_MEAS_20260826_122126.csv | 2 + .../Data/DCT_MEAS_20260826_123117.csv | 2 + .../Data/DCT_MEAS_20260826_123820.csv | 2 + .../Data/DCT_MEAS_20260826_124332.csv | 2 + .../Data/DCT_MEAS_20260826_125651.csv | 2 + .../Data/DCT_MEAS_20260826_125737.csv | 2 + .../Data/DCT_MEAS_20260826_130725.csv | 2 + .../Data/DCT_MEAS_20260826_130726.csv | 2 + .../Data/DCT_MEAS_20260826_131231.csv | 2 + .../Data/DCT_MEAS_20260826_131531.csv | 2 + .../Data/DCT_MEAS_20260826_131601.csv | 2 + .../Data/DCT_MEAS_20260826_132601.csv | 2 + .../Data/DCT_MEAS_20260826_133431.csv | 2 + .../Data/DCT_MEAS_20260826_133606.csv | 2 + .../Data/DCT_MEAS_20260826_133827.csv | 2 + .../Data/DCT_MEAS_20260826_134336.csv | 2 + .../Data/DCT_MEAS_20260826_143129.csv | 2 + .../Data/DCT_MEAS_20260826_143149.csv | 2 + .../Data/DCT_MEAS_20260826_143240.csv | 2 + .../Data/DCT_MEAS_20260826_143911.csv | 2 + .../Data/DCT_MEAS_20260826_144926.csv | 2 + .../Data/DCT_MEAS_20260826_145752.csv | 2 + .../Data/DCT_MEAS_20260826_150646.csv | 2 + .../Data/DCT_MEAS_20260826_151831.csv | 2 + .../Data/DCT_MEAS_20260826_152135.csv | 2 + .../Data/DCT_MEAS_20260826_152412.csv | 2 + .../Data/DCT_MEAS_20260826_153715.csv | 1 + .../Data/DCT_MEAS_20260826_153831.csv | 1 + .../Data/DCT_MEAS_20260826_164312.csv | 1 + .../Data/DCT_MEAS_20260826_170029.csv | 1 + .../Data/DCT_MEAS_20260826_170524.csv | 1 + .../Data/DCT_MEAS_20260826_170931.csv | 1 + .../Data/DCT_MEAS_20260826_171007.csv | 1 + .../Data/DCT_MEAS_20260826_191648.csv | 1 + .../Data/DCT_MEAS_20260827_011900.csv | 1 + .../Data/DCT_MEAS_20260827_011905.csv | 1 + .../Data/DCT_MEAS_20260827_011910.csv | 1 + .../Data/DCT_MEAS_20260827_012019.csv | 1 + .../Data/DCT_MEAS_20260827_012353.csv | 1 + .../Data/DCT_MEAS_20260827_012604.csv | 1 + .../Data/DCT_MEAS_20260827_101607.csv | 1 + .../Data/DCT_MEAS_20260827_105641.csv | 1 + .../Data/DCT_MEAS_20260827_111642.csv | 1 + .../Data/PROJECT_2026-08-27_14-02-11_sm.csv | 1 + .../Data/PROJECT_2026-08-27_14-02-59_sm.csv | 1 + .../Data/PROJECT_2026-08-27_14-19-21_sm.csv | 1 + .../Data/PROJECT_2026-08-27_14-43-44_sm.csv | 1 + .../Data/PROJECT_2026-08-27_14-44-33_sm.csv | 1 + .../Data/PROJECT_2026-08-27_14-56-14_sm.csv | 1 + .../Data/PROJECT_2026-08-27_15-24-37_sm.csv | 1 + .../Data/PROJECT_2026-08-27_15-28-58_sm.csv | 1 + .../Data/PROJECT_2026-08-27_15-32-04_sm.csv | 1 + .../Data/PROJECT_2026-08-27_15-36-31_sm.csv | 1 + .../Data/PROJECT_2026-08-27_15-40-55_sm.csv | 1 + .../Data/PROJECT_2026-08-27_15-46-24_sm.csv | 1 + .../Data/PROJECT_2026-08-27_15-54-39_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-02-37_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-07-33_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-12-23_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-18-35_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-22-25_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-25-45_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-32-10_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-32-11_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-43-08_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-58-04_sm.csv | 1 + .../Data/PROJECT_2026-08-27_16-59-03_sm.csv | 1 + .../Data/PROJECT_2026-08-27_17-02-24_sm.csv | 1 + .../Data/PROJECT_2026-08-28_09-01-33_sm.csv | 1 + .../Data/PROJECT_2026-08-28_09-12-03_sm.csv | 1 + .../Data/PROJECT_2026-08-28_09-14-01_sm.csv | 1 + .../Data/PROJECT_2026-08-28_09-17-26_sm.csv | 1 + .../Data/PROJECT_2026-08-28_09-20-54_sm.csv | 1 + .../Data/PROJECT_2026-08-28_09-27-04_sm.csv | 1 + .../Data/PROJECT_2026-08-28_09-33-36_sm.csv | 1 + .../Data/PROJECT_2026-08-31_09-29-48_sm.csv | 1 + .../Data/PROJECT_2026-08-31_09-30-44_sm.csv | 1 + .../Data/PROJECT_2026-08-31_09-30-59_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-02-16_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-09-55_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-15-12_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-21-05_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-28-00_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-33-36_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-40-16_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-44-57_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-44-59_sm.csv | 1 + .../Data/PROJECT_2026-08-31_10-54-11_sm.csv | 1 + .../Data/PROJECT_2026-08-31_11-04-56_sm.csv | 1 + .../Data/PROJECT_2026-08-31_11-11-16_sm.csv | 1 + .../Data/PROJECT_2026-08-31_11-11-52_sm.csv | 1 + .../Data/PROJECT_2026-08-31_11-24-38_sm.csv | 1 + .../Data/PROJECT_2026-08-31_11-33-45_sm.csv | 1 + .../Data/PROJECT_2026-08-31_11-43-57_sm.csv | 1 + .../Data/PROJECT_2026-08-31_11-56-02_sm.csv | 1 + .../Data/PROJECT_2026-08-31_12-03-17_sm.csv | 1 + .../Data/PROJECT_2026-08-31_12-06-45_sm.csv | 1 + .../Data/PROJECT_2026-08-31_12-13-37_sm.csv | 1 + .../Data/PROJECT_2026-08-31_13-47-22_sm.csv | 1 + .../Data/PROJECT_2026-08-31_13-50-01_sm.csv | 1 + .../Data/PROJECT_2026-08-31_13-56-27_sm.csv | 1 + .../Data/PROJECT_2026-08-31_14-12-07_sm.csv | 1 + .../Data/PROJECT_2026-08-31_14-18-28_sm.csv | 1 + .../Data/PROJECT_2026-08-31_14-20-36_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-03-16_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-10-54_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-11-35_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-12-02_sm.csv | 1 + .../Data/PROJECT_2026-09-02_15-14-38_sm.csv | 1 + .../PROJECT_2026-09-03_16-15-44-313_sm.csv | 1 + .../PROJECT_2026-09-03_16-17-24-075_sm.csv | 1 + .../PROJECT_2026-09-03_16-29-07-862_sm.csv | 1 + .../PROJECT_2026-09-03_16-34-11-885_sm.csv | 1 + .../PROJECT_2026-09-03_16-34-23-695_sm.csv | 1 + .../PROJECT_2026-09-03_16-35-30-974_sm.csv | 1 + .../PROJECT_2026-09-03_16-39-12-921_sm.csv | 1 + .../Data/Sample_2026-08-27_13-13-06_mdb.csv | 1 + .../Data/Sample_2026-08-27_13-16-01_mdb.csv | 1 + .../Data/Sample_2026-08-27_13-19-33_mdb.csv | 1 + .../Data/Sample_2026-08-27_13-34-57_mdb.csv | 1 + .../Data/Sample_2026-08-27_13-36-04_mdb.csv | 1 + .../Data/Sample_2026-08-27_14-02-11_mdb.csv | 1 + .../Data/Sample_2026-08-27_14-02-11_sm.csv | 1 + .../Data/Sample_2026-08-27_14-02-59_mdb.csv | 1 + .../Data/Sample_2026-08-27_14-02-59_sm.csv | 1 + .../Data/Sample_2026-08-27_14-19-21_mdb.csv | 1 + .../Data/Sample_2026-08-27_14-19-21_sm.csv | 1 + .../Data/Sample_2026-08-27_14-43-44_mdb.csv | 1 + .../Data/Sample_2026-08-27_14-43-44_sm.csv | 1 + .../Data/Sample_2026-08-27_14-44-33_mdb.csv | 1 + .../Data/Sample_2026-08-27_14-44-33_sm.csv | 1 + .../Data/Sample_2026-08-27_14-56-14_mdb.csv | 1 + .../Data/Sample_2026-08-27_14-56-14_sm.csv | 1 + .../Data/Sample_2026-08-27_15-24-37_mdb.csv | 1 + .../Data/Sample_2026-08-27_15-24-37_sm.csv | 1 + .../Data/Sample_2026-08-27_15-28-58_mdb.csv | 1 + .../Data/Sample_2026-08-27_15-28-58_sm.csv | 1 + .../Data/Sample_2026-08-27_15-32-04_mdb.csv | 1 + .../Data/Sample_2026-08-27_15-32-04_sm.csv | 1 + .../Data/Sample_2026-08-27_15-36-31_mdb.csv | 1 + .../Data/Sample_2026-08-27_15-36-31_sm.csv | 1 + .../Data/Sample_2026-08-27_15-36-32_mdb.csv | 1 + .../Data/Sample_2026-08-27_15-36-32_sm.csv | 1 + .../Data/Sample_2026-08-27_15-40-55_mdb.csv | 1 + .../Data/Sample_2026-08-27_15-40-55_sm.csv | 1 + .../Data/Sample_2026-08-27_15-46-24_mdb.csv | 1 + .../Data/Sample_2026-08-27_15-46-24_sm.csv | 1 + .../Data/Sample_2026-08-27_15-54-39_mdb.csv | 1 + .../Data/Sample_2026-08-27_15-54-39_sm.csv | 1 + .../Data/Sample_2026-08-27_16-02-37_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-02-37_sm.csv | 1 + .../Data/Sample_2026-08-27_16-07-33_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-07-33_sm.csv | 1 + .../Data/Sample_2026-08-27_16-12-23_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-12-23_sm.csv | 1 + .../Data/Sample_2026-08-27_16-18-35_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-18-35_sm.csv | 1 + .../Data/Sample_2026-08-27_16-22-25_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-22-25_sm.csv | 1 + .../Data/Sample_2026-08-27_16-25-45_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-25-45_sm.csv | 1 + .../Data/Sample_2026-08-27_16-32-10_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-32-10_sm.csv | 1 + .../Data/Sample_2026-08-27_16-32-11_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-32-11_sm.csv | 1 + .../Data/Sample_2026-08-27_16-43-08_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-43-08_sm.csv | 1 + .../Data/Sample_2026-08-27_16-58-04_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-58-04_sm.csv | 1 + .../Data/Sample_2026-08-27_16-59-03_mdb.csv | 1 + .../Data/Sample_2026-08-27_16-59-03_sm.csv | 1 + .../Data/Sample_2026-08-27_17-02-24_mdb.csv | 1 + .../Data/Sample_2026-08-27_17-02-24_sm.csv | 1 + .../Data/Sample_2026-08-28_09-01-33_mdb.csv | 1 + .../Data/Sample_2026-08-28_09-01-33_sm.csv | 1 + .../Data/Sample_2026-08-28_09-12-03_mdb.csv | 1 + .../Data/Sample_2026-08-28_09-12-03_sm.csv | 1 + .../Data/Sample_2026-08-28_09-14-01_mdb.csv | 1 + .../Data/Sample_2026-08-28_09-14-01_sm.csv | 1 + .../Data/Sample_2026-08-28_09-17-26_mdb.csv | 1 + .../Data/Sample_2026-08-28_09-17-26_sm.csv | 1 + .../Data/Sample_2026-08-28_09-20-54_mdb.csv | 1 + .../Data/Sample_2026-08-28_09-20-54_sm.csv | 1 + .../Data/Sample_2026-08-28_09-27-04_mdb.csv | 1 + .../Data/Sample_2026-08-28_09-27-04_sm.csv | 1 + .../Data/Sample_2026-08-28_09-33-36_mdb.csv | 1 + .../Data/Sample_2026-08-28_09-33-36_sm.csv | 1 + .../Data/Sample_2026-08-31_09-29-48_mdb.csv | 1 + .../Data/Sample_2026-08-31_09-29-48_sm.csv | 1 + .../Data/Sample_2026-08-31_09-30-44_mdb.csv | 1 + .../Data/Sample_2026-08-31_09-30-44_sm.csv | 1 + .../Data/Sample_2026-08-31_09-30-59_mdb.csv | 1 + .../Data/Sample_2026-08-31_09-30-59_sm.csv | 1 + .../Data/Sample_2026-08-31_10-02-16_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-02-16_sm.csv | 1 + .../Data/Sample_2026-08-31_10-09-55_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-09-55_sm.csv | 1 + .../Data/Sample_2026-08-31_10-15-12_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-15-12_sm.csv | 1 + .../Data/Sample_2026-08-31_10-21-05_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-21-05_sm.csv | 1 + .../Data/Sample_2026-08-31_10-28-00_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-28-00_sm.csv | 1 + .../Data/Sample_2026-08-31_10-33-36_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-33-36_sm.csv | 1 + .../Data/Sample_2026-08-31_10-40-16_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-40-16_sm.csv | 1 + .../Data/Sample_2026-08-31_10-44-57_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-44-57_sm.csv | 1 + .../Data/Sample_2026-08-31_10-44-59_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-44-59_sm.csv | 1 + .../Data/Sample_2026-08-31_10-54-11_mdb.csv | 1 + .../Data/Sample_2026-08-31_10-54-11_sm.csv | 1 + .../Data/Sample_2026-08-31_11-04-56_mdb.csv | 1 + .../Data/Sample_2026-08-31_11-04-56_sm.csv | 1 + .../Data/Sample_2026-08-31_11-04-57_mdb.csv | 1 + .../Data/Sample_2026-08-31_11-04-57_sm.csv | 1 + .../Data/Sample_2026-08-31_11-11-16_mdb.csv | 1 + .../Data/Sample_2026-08-31_11-11-16_sm.csv | 1 + .../Data/Sample_2026-08-31_11-11-52_mdb.csv | 1 + .../Data/Sample_2026-08-31_11-11-52_sm.csv | 1 + .../Data/Sample_2026-08-31_11-24-38_mdb.csv | 1 + .../Data/Sample_2026-08-31_11-24-38_sm.csv | 1 + .../Data/Sample_2026-08-31_11-33-45_mdb.csv | 1 + .../Data/Sample_2026-08-31_11-33-45_sm.csv | 1 + .../Data/Sample_2026-08-31_11-33-46_mdb.csv | 1 + .../Data/Sample_2026-08-31_11-33-46_sm.csv | 1 + .../Data/Sample_2026-08-31_11-43-57_mdb.csv | 1 + .../Data/Sample_2026-08-31_11-43-57_sm.csv | 1 + .../Data/Sample_2026-08-31_11-56-02_mdb.csv | 1 + .../Data/Sample_2026-08-31_11-56-02_sm.csv | 1 + .../Data/Sample_2026-08-31_12-03-17_mdb.csv | 1 + .../Data/Sample_2026-08-31_12-03-17_sm.csv | 1 + .../Data/Sample_2026-08-31_12-03-18_mdb.csv | 1 + .../Data/Sample_2026-08-31_12-03-18_sm.csv | 1 + .../Data/Sample_2026-08-31_12-06-45_mdb.csv | 1 + .../Data/Sample_2026-08-31_12-06-45_sm.csv | 1 + .../Data/Sample_2026-08-31_12-13-37_mdb.csv | 1 + .../Data/Sample_2026-08-31_12-13-37_sm.csv | 1 + .../Data/Sample_2026-08-31_13-47-22_mdb.csv | 1 + .../Data/Sample_2026-08-31_13-47-22_sm.csv | 1 + .../Data/Sample_2026-08-31_13-50-01_mdb.csv | 1 + .../Data/Sample_2026-08-31_13-50-01_sm.csv | 1 + .../Data/Sample_2026-08-31_13-56-27_mdb.csv | 1 + .../Data/Sample_2026-08-31_13-56-27_sm.csv | 1 + .../Data/Sample_2026-08-31_14-12-07_mdb.csv | 1 + .../Data/Sample_2026-08-31_14-12-07_sm.csv | 1 + .../Data/Sample_2026-08-31_14-18-28_mdb.csv | 1 + .../Data/Sample_2026-08-31_14-18-28_sm.csv | 1 + .../Data/Sample_2026-08-31_14-20-36_mdb.csv | 1 + .../Data/Sample_2026-08-31_14-20-36_sm.csv | 1 + .../Data/Sample_2026-09-02_15-03-16_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-03-16_sm.csv | 1 + .../Data/Sample_2026-09-02_15-03-17_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-03-17_sm.csv | 1 + .../Data/Sample_2026-09-02_15-10-54_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-10-54_sm.csv | 1 + .../Data/Sample_2026-09-02_15-11-35_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-11-35_sm.csv | 1 + .../Data/Sample_2026-09-02_15-11-36_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-11-36_sm.csv | 1 + .../Data/Sample_2026-09-02_15-12-02_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-12-02_sm.csv | 1 + .../Data/Sample_2026-09-02_15-14-38_mdb.csv | 1 + .../Data/Sample_2026-09-02_15-14-38_sm.csv | 1 + .../Sample_2026-09-03_16-15-43-909_mdb.csv | 1 + .../Sample_2026-09-03_16-15-43-909_sm.csv | 1 + .../Sample_2026-09-03_16-15-44-403_mdb.csv | 1 + .../Sample_2026-09-03_16-15-44-403_sm.csv | 1 + .../Sample_2026-09-03_16-17-23-702_mdb.csv | 1 + .../Sample_2026-09-03_16-17-23-702_sm.csv | 1 + .../Sample_2026-09-03_16-17-24-158_mdb.csv | 1 + .../Sample_2026-09-03_16-17-24-158_sm.csv | 1 + .../Sample_2026-09-03_16-29-07-564_mdb.csv | 1 + .../Sample_2026-09-03_16-29-07-564_sm.csv | 1 + .../Sample_2026-09-03_16-29-07-930_mdb.csv | 1 + .../Sample_2026-09-03_16-29-07-930_sm.csv | 1 + .../Sample_2026-09-03_16-34-11-478_mdb.csv | 1 + .../Sample_2026-09-03_16-34-11-478_sm.csv | 1 + .../Sample_2026-09-03_16-34-11-973_mdb.csv | 1 + .../Sample_2026-09-03_16-34-11-973_sm.csv | 1 + .../Sample_2026-09-03_16-34-23-368_mdb.csv | 1 + .../Sample_2026-09-03_16-34-23-368_sm.csv | 1 + .../Sample_2026-09-03_16-34-23-770_mdb.csv | 1 + .../Sample_2026-09-03_16-34-23-770_sm.csv | 1 + .../Sample_2026-09-03_16-35-30-674_mdb.csv | 1 + .../Sample_2026-09-03_16-35-30-674_sm.csv | 1 + .../Sample_2026-09-03_16-35-31-047_mdb.csv | 1 + .../Sample_2026-09-03_16-35-31-047_sm.csv | 1 + .../Sample_2026-09-03_16-39-12-649_mdb.csv | 1 + .../Sample_2026-09-03_16-39-12-649_sm.csv | 1 + .../Sample_2026-09-03_16-39-12-989_mdb.csv | 1 + .../Sample_2026-09-03_16-39-12-989_sm.csv | 1 + Android/app/build.gradle.kts | 144 + Android/app/proguard-rules.pro | 16 + .../dialog/ProjectDialogHoverTest.kt | 92 + .../components/ChartNavigationUiTest.kt | 67 + .../components/CompensationLayoutUiTest.kt | 167 + .../components/ConstantWindowUiTest.kt | 115 + .../monitor/components/GraphSettingsUiTest.kt | 115 + .../components/WifiManualConnectionUiTest.kt | 76 + .../components/WindowActivationTest.kt | 144 + .../monitor/components/WindowParityUiTest.kt | 76 + .../WorkspaceTouchNavigationUiTest.kt | 231 ++ Android/app/src/main/AndroidManifest.xml | 45 + .../com/dct/app/core/common/BaseViewModel.kt | 65 + .../java/com/dct/app/core/common/DctResult.kt | 45 + .../core/common/DefaultDispatcherProvider.kt | 12 + .../dct/app/core/common/DispatcherProvider.kt | 11 + .../java/com/dct/app/core/common/EventBus.kt | 25 + .../dct/app/core/common/PacketDistributor.kt | 21 + .../app/core/engine/XcpDaqStreamDecoder.kt | 118 + .../java/com/dct/app/core/error/DctError.kt | 44 + .../dct/app/core/formula/FormulaEvaluator.kt | 366 ++ .../dct/app/core/formula/SMLinEvaluator.kt | 109 + .../java/com/dct/app/core/theme/DctColors.kt | 92 + .../java/com/dct/app/core/theme/DctTheme.kt | 58 + .../com/dct/app/core/theme/DctTypography.kt | 121 + .../dct/app/core/util/TextStreamDecoder.kt | 129 + .../BluetoothCommunicationManager.kt | 268 ++ .../WifiConnectionPreferences.kt | 27 + .../data/communication/WifiDirectManager.kt | 434 +++ .../data/communication/WifiSocketManager.kt | 327 ++ .../app/data/logger/CsvMeasurementLogger.kt | 100 + .../app/data/logger/MeasurementLogPaths.kt | 23 + .../app/data/logger/Mf4MeasurementLogger.kt | 156 + .../maintenance/DeviceMaintenanceProtocol.kt | 93 + .../data/maintenance/FirmwareFileParser.kt | 136 + .../data/maintenance/MaintenanceService.kt | 366 ++ .../app/data/maintenance/SeedKeyProvider.kt | 38 + .../com/dct/app/data/mdb/AttrConverter.kt | 133 + .../com/dct/app/data/mdb/MdbSchemaParser.kt | 374 ++ .../dct/app/data/mdb/VirtualMdbGenerator.kt | 208 ++ .../java/com/dct/app/data/parser/A2lParser.kt | 823 +++++ .../com/dct/app/data/parser/CanDbcParser.kt | 129 + .../app/data/protocol/ChecksumCalculator.kt | 63 + .../data/protocol/DctCalibrationProtocol.kt | 58 + .../dct/app/data/protocol/DctPacketCodec.kt | 135 + .../app/data/protocol/DctProtocolRequests.kt | 363 ++ .../app/data/protocol/DctResponseValidator.kt | 43 + .../data/protocol/DctStreamPayloadParser.kt | 208 ++ .../dct/app/data/protocol/MdbRecordDecoder.kt | 78 + .../com/dct/app/data/protocol/PacketParser.kt | 29 + .../data/protocol/SlidingRingBufferParser.kt | 207 ++ .../dct/app/data/protocol/XcpValueCodec.kt | 41 + .../app/data/repository/DpcProjectManager.kt | 1041 ++++++ .../app/data/repository/DpcWorkspaceJson.kt | 141 + .../app/data/repository/MdbRepositoryImpl.kt | 920 +++++ .../data/repository/PacketRepositoryImpl.kt | 394 +++ .../data/ringbuffer/TimeSeriesRingBuffer.kt | 234 ++ .../app/domain/model/WorkspacePageState.kt | 36 + .../dct/app/domain/model/a2l/A2lDataType.kt | 175 + .../app/domain/model/chart/AxisPosition.kt | 34 + .../app/domain/model/chart/ChartAxisConfig.kt | 47 + .../app/domain/model/chart/ChartAxisTicks.kt | 158 + .../domain/model/chart/ChartDrawingRules.kt | 33 + .../domain/model/chart/ChartRenderSnapshot.kt | 39 + .../domain/model/chart/ChartSignalSeries.kt | 30 + .../model/chart/ChartValueInterpolation.kt | 26 + .../model/chart/DownsamplingAlgorithms.kt | 208 ++ .../domain/model/chart/InterpolationMode.kt | 16 + .../chart/MultiAxisCoordinateTransformer.kt | 208 ++ .../domain/model/chart/TimeAxisViewport.kt | 53 + .../domain/model/chart/TimeWindowPreset.kt | 42 + .../domain/model/dashboard/TileAlertState.kt | 10 + .../domain/model/dashboard/TileWidgetItem.kt | 85 + .../domain/model/formula/FormulaDefinition.kt | 16 + .../model/maintenance/MaintenanceModels.kt | 51 + .../com/dct/app/domain/model/mdb/MdbModels.kt | 277 ++ .../app/domain/model/project/DpcProject.kt | 124 + .../domain/model/project/DpcWorkspaceState.kt | 89 + .../app/domain/model/protocol/DctCommand.kt | 115 + .../protocol/DctMeasurementConfiguration.kt | 21 + .../app/domain/model/protocol/DctPacket.kt | 71 + .../app/domain/repository/MdbRepository.kt | 155 + .../app/domain/repository/PacketRepository.kt | 104 + .../usecase/CreateStandardSampleMdbUseCase.kt | 22 + .../domain/usecase/GetMdbDatabaseUseCase.kt | 26 + .../dct/app/domain/usecase/SaveMdbUseCase.kt | 27 + .../usecase/UpdateCompensationMapUseCase.kt | 36 + .../domain/usecase/UpdateConstantUseCase.kt | 15 + .../usecase/chart/ChartBatchRenderer.kt | 759 ++++ .../usecase/chart/MultiAxisChartController.kt | 80 + .../usecase/chart/ThrottledBatchRenderer.kt | 8 + .../calibration/CalibrationInspectorView.kt | 113 + .../calibration/CalibrationModels.kt | 49 + .../calibration/CalibrationViewModel.kt | 437 +++ .../calibration/ConstantTuningView.kt | 263 ++ .../calibration/MapTableEditor.kt | 457 +++ .../presentation/chart/ChartCursorTooltip.kt | 25 + .../presentation/chart/GraphYAxisDialog.kt | 156 + .../presentation/chart/MultiXGraphCanvas.kt | 1017 ++++++ .../app/presentation/chart/MultiXGraphView.kt | 169 + .../chart/SignalDragDropManager.kt | 110 + .../dashboard/DashboardTileView.kt | 237 ++ .../dashboard/TileSparklineDashboard.kt | 65 + .../dialog/CommunicationDeviceListDialogs.kt | 978 ++++++ .../presentation/dialog/ProjectFileDialogs.kt | 497 +++ .../dialog/WifiManualConnectionForm.kt | 65 + .../dct/app/presentation/main/DctTabletApp.kt | 64 + .../dct/app/presentation/main/MainActivity.kt | 103 + .../app/presentation/main/MainViewModel.kt | 3102 +++++++++++++++++ .../presentation/main/ThreePaneLayoutState.kt | 69 + .../presentation/main/WorkspaceStateMapper.kt | 45 + .../maintenance/MaintenanceDialog.kt | 317 ++ .../dct/app/presentation/mdi/MdiViewModel.kt | 513 +++ .../com/dct/app/presentation/mdi/MdiWindow.kt | 67 + .../presentation/mdi/MdiWorkspaceManager.kt | 313 ++ .../mdi/WindowPageHoverTracker.kt | 32 + .../monitor/DiagnosticDataMonitorScreen.kt | 1341 +++++++ .../monitor/EmptyCanvasGuideView.kt | 389 +++ .../monitor/components/AlphaSignal.kt | 133 + .../components/AlphanumericalWindowView.kt | 670 ++++ .../monitor/components/FormulaEditDialog.kt | 274 ++ .../monitor/components/FormulaWindowView.kt | 304 ++ .../monitor/components/GraphWindowView.kt | 584 ++++ .../monitor/components/MdiSubWindowFrame.kt | 786 +++++ .../monitor/components/MonitorDesignTokens.kt | 25 + .../components/SensorModuleWindowView.kt | 354 ++ .../components/SignalListWindowView.kt | 53 + .../monitor/components/TwoFingerFling.kt | 39 + .../monitor/components/TwoFingerGesture.kt | 57 + .../monitor/components/WindowActivation.kt | 45 + .../components/WindowActivationGesture.kt | 14 + .../monitor/components/WorkspacePageTabBar.kt | 160 + .../monitor/components/WorkspaceRibbonBar.kt | 344 ++ .../monitor/components/WorkspaceScrollbars.kt | 151 + .../components/WorkspaceTouchNavigation.kt | 158 + .../presentation/project/NewProjectDialog.kt | 714 ++++ .../project/SensorModuleConfigDialog.kt | 345 ++ .../signaltree/A2lSignalTableView.kt | 906 +++++ .../signaltree/CanSignalSorting.kt | 29 + .../signaltree/CanSignalTableView.kt | 232 ++ .../signaltree/CompensationCurveGeometry.kt | 34 + .../signaltree/CompensationMapView.kt | 472 +++ .../signaltree/CompensationNumberFormat.kt | 13 + .../signaltree/CompensationTable.kt | 147 + .../signaltree/ConstantPresentation.kt | 23 + .../signaltree/ConstantTableView.kt | 190 + .../signaltree/MdbSignalTableView.kt | 214 ++ .../signaltree/SignalTreeModels.kt | 82 + .../signaltree/SignalTreeViewModel.kt | 434 +++ .../signaltree/TableCommonComponents.kt | 706 ++++ .../presentation/signaltree/WindowUiState.kt | 60 + .../signaltree/WindowsClassicTreeView.kt | 498 +++ .../src/main/java/javax/script/Bindings.java | 20 + .../main/java/javax/script/ScriptContext.java | 25 + .../main/java/javax/script/ScriptEngine.java | 28 + .../javax/script/ScriptEngineFactory.java | 18 + .../javax/script/ScriptEngineManager.java | 67 + .../java/javax/script/ScriptException.java | 39 + .../java/javax/script/SimpleBindings.java | 104 + .../javax/script/SimpleScriptContext.java | 161 + Android/app/src/main/jniLibs/README.md | 16 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 7837 bytes .../mipmap-hdpi/ic_launcher_adaptive_back.png | Bin 0 -> 12134 bytes .../mipmap-hdpi/ic_launcher_adaptive_fore.png | Bin 0 -> 21743 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 4108 bytes .../mipmap-mdpi/ic_launcher_adaptive_back.png | Bin 0 -> 6146 bytes .../mipmap-mdpi/ic_launcher_adaptive_fore.png | Bin 0 -> 11288 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 12626 bytes .../ic_launcher_adaptive_back.png | Bin 0 -> 19419 bytes .../ic_launcher_adaptive_fore.png | Bin 0 -> 35137 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 24423 bytes .../ic_launcher_adaptive_back.png | Bin 0 -> 35759 bytes .../ic_launcher_adaptive_fore.png | Bin 0 -> 65109 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 38806 bytes .../ic_launcher_adaptive_back.png | Bin 0 -> 54433 bytes .../ic_launcher_adaptive_fore.png | Bin 0 -> 101108 bytes Android/app/src/main/res/values/colors.xml | 21 + Android/app/src/main/res/values/strings.xml | 4 + Android/app/src/main/res/values/themes.xml | 8 + .../java/com/dct/app/tests/A2lParserTest.kt | 177 + .../com/dct/app/tests/AttrConverterTest.kt | 213 ++ .../dct/app/tests/CalibrationEditorTest.kt | 236 ++ .../com/dct/app/tests/CanDbcParserTest.kt | 108 + .../app/tests/ChartContinuousScrollTest.kt | 112 + .../dct/app/tests/ChartCursorStabilityTest.kt | 138 + .../dct/app/tests/ChartCursorTooltipTest.kt | 49 + .../com/dct/app/tests/ChartNavigationTest.kt | 272 ++ .../app/tests/ChartStoppedNavigationTest.kt | 142 + .../com/dct/app/tests/ChartXAxisParityTest.kt | 242 ++ .../dct/app/tests/ChecksumCalculatorTest.kt | 108 + .../dct/app/tests/CompensationLayoutTest.kt | 128 + .../dct/app/tests/ConstantPresentationTest.kt | 50 + .../dct/app/tests/DctProtocolRequestsTest.kt | 159 + .../app/tests/DctStreamPayloadParserTest.kt | 146 + .../dct/app/tests/DctVersionResponseTest.kt | 61 + .../dct/app/tests/DpcProjectManagerTest.kt | 539 +++ .../app/tests/DpcWorkspacePersistenceTest.kt | 106 + .../dct/app/tests/FirmwareFileParserTest.kt | 51 + .../com/dct/app/tests/FormulaEvaluatorTest.kt | 96 + .../dct/app/tests/FormulaIntegrationTest.kt | 87 + .../dct/app/tests/GapClosureRegressionTest.kt | 161 + .../app/tests/GraphSettingsRegressionTest.kt | 136 + .../java/com/dct/app/tests/GraphTypeTest.kt | 79 + .../com/dct/app/tests/JackcessLoadUnitTest.kt | 94 + .../com/dct/app/tests/MainViewModelTest.kt | 1086 ++++++ .../dct/app/tests/MaintenanceServiceTest.kt | 42 + .../dct/app/tests/MdbRepositoryCrudTest.kt | 253 ++ .../com/dct/app/tests/MdbSchemaParserTest.kt | 246 ++ .../dct/app/tests/MdiWorkspaceManagerTest.kt | 231 ++ .../dct/app/tests/MeasurementLogPathsTest.kt | 27 + .../dct/app/tests/MeasurementLoggerTest.kt | 101 + .../tests/MeasurementRestartRegressionTest.kt | 174 + .../dct/app/tests/MinMaxDownsamplerTest.kt | 127 + .../tests/MultiAxisCoordinateTransformTest.kt | 189 + .../app/tests/PacketFramingAndCodecTest.kt | 123 + .../app/tests/PacketRepositoryResponseTest.kt | 93 + .../com/dct/app/tests/SMLinEvaluatorTest.kt | 72 + .../app/tests/SensorModuleIntegrationTest.kt | 90 + .../dct/app/tests/SensorModulePacketTest.kt | 75 + .../app/tests/SignalDragDropManagerTest.kt | 97 + .../dct/app/tests/SignalTreeViewModelTest.kt | 292 ++ .../app/tests/SlidingRingBufferParserTest.kt | 220 ++ .../dct/app/tests/TestDispatcherProvider.kt | 14 + .../dct/app/tests/TimeSeriesRingBufferTest.kt | 142 + .../com/dct/app/tests/TwoFingerFlingTest.kt | 108 + .../com/dct/app/tests/TwoFingerGestureTest.kt | 79 + .../app/tests/WifiConnectionGenerationTest.kt | 36 + .../tests/WifiConnectionPreferencesTest.kt | 64 + .../app/tests/WindowActivationGestureTest.kt | 47 + .../app/tests/WindowPageHoverTrackerTest.kt | 55 + .../com/dct/app/tests/WindowParityTest.kt | 104 + .../java/com/dct/app/tests/XcpEngineTest.kt | 136 + Android/build.gradle.kts | 6 + Android/gradle.properties | 28 + Android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63721 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + Android/gradlew | 249 ++ Android/gradlew.bat | 92 + Android/launch_test.ps1 | 10 + Android/run_tests.ps1 | 8 + Android/scripts/verify-local.ps1 | 51 + Android/scripts/verify-ui-local.ps1 | 21 + Android/settings.gradle.kts | 26 + DCT_test | 1 + 781 files changed, 46868 insertions(+) create mode 100644 .gitignore create mode 100644 Android/.gitignore create mode 100644 Android/DCT Work/Configuration/AUTO_TEST.dpc create mode 100644 Android/DCT Work/Configuration/Sample.mdb.dpc create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_11-37-04_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_11-37-26_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_11-40-16_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_15-50-24_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_15-51-12_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_15-53-02_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_15-54-20_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_15-57-34_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_16-00-50_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_16-33-18_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_16-40-01_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_16-41-17_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_16-52-25_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_22-38-04_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_22-40-53_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-02_22-45-02_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_09-41-56_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_09-45-18_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_10-03-50_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_10-14-02_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_10-16-42_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_10-28-30_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_10-37-10_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_10-40-28_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_10-50-58_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_10-52-47_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_11-42-05_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_11-44-21_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_11-55-33_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_11-57-04_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_14-07-08_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_14-17-04_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_14-21-44_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_14-29-41_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_14-30-37_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_14-43-35_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_14-55-48-594_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_15-00-38-186_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_15-02-33-023_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_15-24-29-617_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_15-35-27-968_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_15-41-40-020_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_15-43-47-387_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_15-45-38-906_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_15-52-50-791_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_16-07-21-559_sm.csv create mode 100644 Android/DCT Work/Data/PROJECT_2026-09-03_16-09-22-691_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_11-37-03_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_11-37-03_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_11-37-04_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_11-37-04_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_11-37-26_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_11-37-26_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_11-40-16_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_11-40-16_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-50-24_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-50-24_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-51-12_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-51-12_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-53-02_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-53-02_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-54-20_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-54-20_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-57-33_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-57-33_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-57-34_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_15-57-34_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-00-50_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-00-50_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-33-18_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-33-18_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-40-00_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-40-00_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-40-01_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-40-01_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-41-17_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-41-17_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-52-25_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_16-52-25_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_22-38-04_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_22-38-04_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_22-40-53_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_22-40-53_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_22-45-02_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-02_22-45-02_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_09-38-51_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_09-38-51_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_09-41-56_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_09-41-56_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_09-45-18_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_09-45-18_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-03-49_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-03-49_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-03-50_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-03-50_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-14-02_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-14-02_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-14-03_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-14-03_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-16-42_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-16-42_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-28-30_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-28-30_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-37-10_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-37-10_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-40-28_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-40-28_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-50-58_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-50-58_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-52-47_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_10-52-47_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_11-42-05_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_11-42-05_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_11-44-21_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_11-44-21_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_11-55-33_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_11-55-33_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_11-57-04_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_11-57-04_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-07-08_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-07-08_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-17-04_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-17-04_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-21-44_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-21-44_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-29-41_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-29-41_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-30-37_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-30-37_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-43-35_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-43-35_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-55-48-378_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-55-48-378_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-55-48-638_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_14-55-48-638_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-00-37-948_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-00-37-948_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-00-38-235_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-00-38-235_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-02-32-817_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-02-32-817_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-02-33-060_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-02-33-060_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-24-29-390_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-24-29-390_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-24-29-660_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-24-29-660_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-35-27-744_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-35-27-744_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-35-28-014_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-35-28-014_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-41-39-794_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-41-39-794_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-41-40-065_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-41-40-065_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-43-47-160_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-43-47-160_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-43-47-433_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-43-47-433_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-45-38-692_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-45-38-692_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-45-38-958_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-45-38-958_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-52-50-552_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-52-50-552_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-52-50-848_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_15-52-50-848_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_16-07-21-345_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_16-07-21-345_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_16-07-21-603_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_16-07-21-603_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_16-09-22-454_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_16-09-22-454_sm.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_16-09-22-738_mdb.csv create mode 100644 Android/DCT Work/Data/Sample_2026-09-03_16-09-22-738_sm.csv create mode 100644 Android/app/.gitignore create mode 100644 Android/app/DCT Work/AUTO_TEST.dpc create mode 100644 Android/app/DCT Work/Configuration/AUTO_TEST.dpc create mode 100644 Android/app/DCT Work/Configuration/Sample.mdb.dpc create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_112815.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_113103.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_125234.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_130759.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_131946.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_132323.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_132750.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_133030.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_133540.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_133541.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_133902.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_134652.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_134908.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_135149.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_135542.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_135806.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_140830.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_141011.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_141809.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_142203.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_142429.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_142832.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_143145.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_144157.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_145628.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_145757.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_150926.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_151422.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_151832.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_160435.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_164514.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_164814.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_165257.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_165743.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_170527.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_170528.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260825_170947.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_091323.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_091939.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_100240.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_100519.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_100941.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_101242.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_102051.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_102414.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_102713.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_103828.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_104635.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_104636.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_105358.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_105359.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_105503.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_110114.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_110718.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_111359.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_112319.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_113309.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_113350.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_121417.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_122126.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_123117.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_123820.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_124332.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_125651.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_125737.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_130725.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_130726.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_131231.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_131531.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_131601.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_132601.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_133431.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_133606.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_133827.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_134336.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_143129.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_143149.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_143240.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_143911.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_144926.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_145752.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_150646.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_151831.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_152135.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_152412.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_153715.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_153831.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_164312.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_170029.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_170524.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_170931.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_171007.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260826_191648.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260827_011900.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260827_011905.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260827_011910.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260827_012019.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260827_012353.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260827_012604.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260827_101607.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260827_105641.csv create mode 100644 Android/app/DCT Work/Data/DCT_MEAS_20260827_111642.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_14-02-11_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_14-02-59_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_14-19-21_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_14-43-44_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_14-44-33_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_14-56-14_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_15-24-37_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_15-28-58_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_15-32-04_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_15-36-31_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_15-40-55_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_15-46-24_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_15-54-39_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-02-37_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-07-33_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-12-23_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-18-35_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-22-25_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-25-45_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-32-10_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-32-11_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-43-08_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-58-04_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_16-59-03_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-27_17-02-24_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-28_09-01-33_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-28_09-12-03_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-28_09-14-01_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-28_09-17-26_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-28_09-20-54_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-28_09-27-04_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-28_09-33-36_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_09-29-48_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_09-30-44_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_09-30-59_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-02-16_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-09-55_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-15-12_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-21-05_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-28-00_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-33-36_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-40-16_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-44-57_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-44-59_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_10-54-11_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_11-04-56_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_11-11-16_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_11-11-52_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_11-24-38_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_11-33-45_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_11-43-57_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_11-56-02_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_12-03-17_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_12-06-45_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_12-13-37_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_13-47-22_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_13-50-01_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_13-56-27_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_14-12-07_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_14-18-28_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-08-31_14-20-36_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-02_15-03-16_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-02_15-10-54_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-02_15-11-35_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-02_15-12-02_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-02_15-14-38_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-03_16-15-44-313_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-03_16-17-24-075_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-03_16-29-07-862_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-03_16-34-11-885_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-03_16-34-23-695_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-03_16-35-30-974_sm.csv create mode 100644 Android/app/DCT Work/Data/PROJECT_2026-09-03_16-39-12-921_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_13-13-06_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_13-16-01_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_13-19-33_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_13-34-57_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_13-36-04_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-02-11_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-02-11_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-02-59_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-02-59_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-19-21_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-19-21_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-43-44_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-43-44_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-44-33_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-44-33_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-56-14_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_14-56-14_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-24-37_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-24-37_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-28-58_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-28-58_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-32-04_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-32-04_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-36-31_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-36-31_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-36-32_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-36-32_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-40-55_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-40-55_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-46-24_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-46-24_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-54-39_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_15-54-39_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-02-37_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-02-37_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-07-33_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-07-33_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-12-23_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-12-23_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-18-35_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-18-35_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-22-25_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-22-25_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-25-45_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-25-45_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-32-10_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-32-10_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-32-11_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-32-11_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-43-08_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-43-08_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-58-04_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-58-04_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-59-03_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_16-59-03_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_17-02-24_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-27_17-02-24_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-01-33_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-01-33_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-12-03_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-12-03_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-14-01_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-14-01_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-17-26_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-17-26_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-20-54_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-20-54_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-27-04_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-27-04_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-33-36_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-28_09-33-36_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_09-29-48_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_09-29-48_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_09-30-44_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_09-30-44_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_09-30-59_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_09-30-59_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-02-16_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-02-16_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-09-55_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-09-55_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-15-12_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-15-12_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-21-05_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-21-05_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-28-00_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-28-00_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-33-36_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-33-36_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-40-16_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-40-16_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-44-57_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-44-57_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-44-59_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-44-59_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-54-11_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_10-54-11_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-04-56_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-04-56_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-04-57_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-04-57_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-11-16_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-11-16_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-11-52_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-11-52_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-24-38_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-24-38_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-33-45_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-33-45_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-33-46_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-33-46_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-43-57_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-43-57_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-56-02_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_11-56-02_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_12-03-17_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_12-03-17_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_12-03-18_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_12-03-18_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_12-06-45_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_12-06-45_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_12-13-37_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_12-13-37_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_13-47-22_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_13-47-22_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_13-50-01_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_13-50-01_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_13-56-27_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_13-56-27_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_14-12-07_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_14-12-07_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_14-18-28_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_14-18-28_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_14-20-36_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-08-31_14-20-36_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-03-16_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-03-16_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-03-17_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-03-17_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-10-54_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-10-54_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-11-35_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-11-35_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-11-36_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-11-36_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-12-02_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-12-02_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-14-38_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-02_15-14-38_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-15-43-909_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-15-43-909_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-15-44-403_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-15-44-403_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-17-23-702_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-17-23-702_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-17-24-158_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-17-24-158_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-564_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-564_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-930_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-29-07-930_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-478_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-478_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-973_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-34-11-973_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-368_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-368_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-770_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-34-23-770_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-35-30-674_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-35-30-674_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-35-31-047_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-35-31-047_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-649_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-649_sm.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-989_mdb.csv create mode 100644 Android/app/DCT Work/Data/Sample_2026-09-03_16-39-12-989_sm.csv create mode 100644 Android/app/build.gradle.kts create mode 100644 Android/app/proguard-rules.pro create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/dialog/ProjectDialogHoverTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ChartNavigationUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/CompensationLayoutUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/ConstantWindowUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/GraphSettingsUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiManualConnectionUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowActivationTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WindowParityUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigationUiTest.kt create mode 100644 Android/app/src/main/AndroidManifest.xml create mode 100644 Android/app/src/main/java/com/dct/app/core/common/BaseViewModel.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/common/DctResult.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/common/DefaultDispatcherProvider.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/common/DispatcherProvider.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/common/EventBus.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/common/PacketDistributor.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/engine/XcpDaqStreamDecoder.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/error/DctError.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/formula/FormulaEvaluator.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/formula/SMLinEvaluator.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/theme/DctColors.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/theme/DctTheme.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/theme/DctTypography.kt create mode 100644 Android/app/src/main/java/com/dct/app/core/util/TextStreamDecoder.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/communication/BluetoothCommunicationManager.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/communication/WifiDirectManager.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/logger/CsvMeasurementLogger.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/logger/MeasurementLogPaths.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/logger/Mf4MeasurementLogger.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/maintenance/DeviceMaintenanceProtocol.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/maintenance/FirmwareFileParser.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/maintenance/MaintenanceService.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/maintenance/SeedKeyProvider.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/mdb/AttrConverter.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/mdb/MdbSchemaParser.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/mdb/VirtualMdbGenerator.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/parser/A2lParser.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/parser/CanDbcParser.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/ChecksumCalculator.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/DctCalibrationProtocol.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/DctPacketCodec.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/DctResponseValidator.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/DctStreamPayloadParser.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/MdbRecordDecoder.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/PacketParser.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/protocol/XcpValueCodec.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/repository/DpcProjectManager.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/repository/DpcWorkspaceJson.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/repository/MdbRepositoryImpl.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/ringbuffer/TimeSeriesRingBuffer.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/WorkspacePageState.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/a2l/A2lDataType.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/AxisPosition.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisConfig.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/ChartAxisTicks.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/ChartDrawingRules.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/ChartRenderSnapshot.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/ChartSignalSeries.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/ChartValueInterpolation.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/DownsamplingAlgorithms.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/InterpolationMode.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/MultiAxisCoordinateTransformer.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/TimeAxisViewport.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/chart/TimeWindowPreset.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileAlertState.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/dashboard/TileWidgetItem.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/formula/FormulaDefinition.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/maintenance/MaintenanceModels.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/mdb/MdbModels.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/project/DpcProject.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/project/DpcWorkspaceState.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/protocol/DctCommand.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/model/protocol/DctPacket.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/repository/MdbRepository.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/usecase/CreateStandardSampleMdbUseCase.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/usecase/GetMdbDatabaseUseCase.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/usecase/SaveMdbUseCase.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/usecase/UpdateCompensationMapUseCase.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/usecase/UpdateConstantUseCase.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/usecase/chart/MultiAxisChartController.kt create mode 100644 Android/app/src/main/java/com/dct/app/domain/usecase/chart/ThrottledBatchRenderer.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationInspectorView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationModels.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/calibration/CalibrationViewModel.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/calibration/ConstantTuningView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/calibration/MapTableEditor.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/chart/ChartCursorTooltip.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/chart/GraphYAxisDialog.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphCanvas.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/chart/MultiXGraphView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/chart/SignalDragDropManager.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/dashboard/DashboardTileView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/dashboard/TileSparklineDashboard.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/dialog/CommunicationDeviceListDialogs.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/dialog/ProjectFileDialogs.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/dialog/WifiManualConnectionForm.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/main/DctTabletApp.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/main/ThreePaneLayoutState.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/main/WorkspaceStateMapper.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/mdi/MdiViewModel.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWindow.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/mdi/MdiWorkspaceManager.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/mdi/WindowPageHoverTracker.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphaSignal.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/AlphanumericalWindowView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaEditDialog.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/FormulaWindowView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/GraphWindowView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/MdiSubWindowFrame.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/MonitorDesignTokens.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/SensorModuleWindowView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/SignalListWindowView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerFling.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/TwoFingerGesture.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/WindowActivation.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/WindowActivationGesture.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspacePageTabBar.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceScrollbars.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceTouchNavigation.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/project/NewProjectDialog.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/project/SensorModuleConfigDialog.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/A2lSignalTableView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalSorting.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/CanSignalTableView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationCurveGeometry.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationMapView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationNumberFormat.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/CompensationTable.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantPresentation.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/ConstantTableView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/MdbSignalTableView.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeModels.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/SignalTreeViewModel.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/TableCommonComponents.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowUiState.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/signaltree/WindowsClassicTreeView.kt create mode 100644 Android/app/src/main/java/javax/script/Bindings.java create mode 100644 Android/app/src/main/java/javax/script/ScriptContext.java create mode 100644 Android/app/src/main/java/javax/script/ScriptEngine.java create mode 100644 Android/app/src/main/java/javax/script/ScriptEngineFactory.java create mode 100644 Android/app/src/main/java/javax/script/ScriptEngineManager.java create mode 100644 Android/app/src/main/java/javax/script/ScriptException.java create mode 100644 Android/app/src/main/java/javax/script/SimpleBindings.java create mode 100644 Android/app/src/main/java/javax/script/SimpleScriptContext.java create mode 100644 Android/app/src/main/jniLibs/README.md create mode 100644 Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 Android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png create mode 100644 Android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png create mode 100644 Android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png create mode 100644 Android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png create mode 100644 Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png create mode 100644 Android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png create mode 100644 Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png create mode 100644 Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png create mode 100644 Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png create mode 100644 Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png create mode 100644 Android/app/src/main/res/values/colors.xml create mode 100644 Android/app/src/main/res/values/strings.xml create mode 100644 Android/app/src/main/res/values/themes.xml create mode 100644 Android/app/src/test/java/com/dct/app/tests/A2lParserTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/AttrConverterTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/CalibrationEditorTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/CanDbcParserTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChartContinuousScrollTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChartCursorStabilityTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChartCursorTooltipTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChartXAxisParityTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ChecksumCalculatorTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/CompensationLayoutTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/ConstantPresentationTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/DctStreamPayloadParserTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/DctVersionResponseTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/DpcProjectManagerTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/DpcWorkspacePersistenceTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/FirmwareFileParserTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/FormulaEvaluatorTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/FormulaIntegrationTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/GapClosureRegressionTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/GraphSettingsRegressionTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/GraphTypeTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/JackcessLoadUnitTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MaintenanceServiceTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MdbRepositoryCrudTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MdbSchemaParserTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MdiWorkspaceManagerTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MeasurementLogPathsTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MeasurementLoggerTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MinMaxDownsamplerTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/MultiAxisCoordinateTransformTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/PacketFramingAndCodecTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/SMLinEvaluatorTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/SensorModuleIntegrationTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/SensorModulePacketTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/SignalDragDropManagerTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/SignalTreeViewModelTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/TestDispatcherProvider.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/TimeSeriesRingBufferTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/TwoFingerFlingTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/TwoFingerGestureTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WindowActivationGestureTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WindowPageHoverTrackerTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WindowParityTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/XcpEngineTest.kt create mode 100644 Android/build.gradle.kts create mode 100644 Android/gradle.properties create mode 100644 Android/gradle/wrapper/gradle-wrapper.jar create mode 100644 Android/gradle/wrapper/gradle-wrapper.properties create mode 100644 Android/gradlew create mode 100644 Android/gradlew.bat create mode 100644 Android/launch_test.ps1 create mode 100644 Android/run_tests.ps1 create mode 100644 Android/scripts/verify-local.ps1 create mode 100644 Android/scripts/verify-ui-local.ps1 create mode 100644 Android/settings.gradle.kts create mode 160000 DCT_test 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 0000000000000000000000000000000000000000..7ff1186d256dce43adc51b9cbfc729b610d9fcd4 GIT binary patch literal 7837 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d8c21b9783b5d67f942ca0ec7f5f1a6f6c0126d3 GIT binary patch literal 12134 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 A)&Kwi literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3723bee5a0a144d417e34a8adbc496c87ef44807 GIT binary patch literal 21743 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

Mr?dsqS&6?a&^R`;>vlptcTQ1p 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 KMNUMnLSTYM4=ZB; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..69894d16a7cf8e08e71b9d1f2f767e7a6e5c1c87 GIT binary patch literal 4108 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Μ~?K~gRvht$CSAMPmQ7lyRFY1U~9x8 zEtcQ8fV!)j(3~ih`ER23MmTRIYv0JYwHB_qI;>+S(%Ls1&*PhO&amI%=zzH+@~@2M zTL|Zkr0g3Rr>$d~n>DMrj^w=I+7<3D7-$FRP3d0t#knhx!kAJZKDe|IT;6~NoboVo zFvfeq!~@NLG7pM`jIS&(q<}Z|(^S>U1(c{YSj8jP$`#)*$GNIuwLm%&-ew&ZT)=wo zDq|&y{36-;bHaS>Cu2`(<}+9M*`GCzYhOKH%wy`vx=J}VD)*2-4K?>M$8(=Av6gy* z!Op6SIL4lKNR_Hvh0(L@V+}?<7rU>d!-(x<-oBA>k(v&D=1ka6I8K<$f9sfY+~Mee zxg+uqisn}c=Z&P&PdoE9u5-8E81o)$d{I@C+G_7E7;GzUo2WgUeCv%d0cXw%=sa|5 z&VVsCXTZn<4QRTxdV#n?0FqFcwF{#L80vs!A&fknV;Xxc(11NYoE3Lum)|h2ve&)h zC>cF#&Dho*WzI04Hhss573ZA!Tm4yMgwDU-u))mW9`LCD!KFW zYct<>U}qh7^f>S4(zkV7x0Pe_wJ*mz??7-o*O^l)ntkJ;eriWieZJjYFz7o0yd&L# zW5fO#aGkRndLFtpH)7<028>KINn=$yAR6+2v*&G`g zr=3efQ^<%@Tx^j9JL@8zMWGyTt7jh-J<#d+qy#YPK;!loR8%YXT(?ramD7SfR+@`pyTaUyHRjUVuQ1=?fBPJn&h>AW z{mj@8B(d4x{354a-1$u;)8pQ+!o3B*N1n^3+4^(d>9qC@$D73A>EDMz>SmqH-W+R$ zG4}j-=C5sK<^mHLx<%(R z@7VQId7KpU3G0lE)2{03u&yyupSGSZb1RMcE4)BoT{wQDvy-$}#Va0Yz$&$I&KX-+ z%xXN?v}uce{;RT6V7=k!8ldN0q-hK+jPZ~hf>8&kCkDkjctmql#tHL_G7kFMOWka* zDy%zjRvo!<&ziT|tYP0UZ_JBjuEVMWojZES_+Yx${M@HA!Q0v3xYkBok50ewfnXfr=sur4uYTLb6X>4J+BAfCoj-!A#KUqA!)I2|~1nX48? z9XK|ssx|bdl?y0QYv9;uPhD}sYK2$cakcgh^Y@TCTW=s^RnznOMV|!bt*^V-@s266 z>o55+VSQyBN8gncVcw8Cv`O~FH*c(5^*s!95$xZv%tK#wI4rR3h*Vu$(8-9oaV;vD z*#*_lWX92N2Hk@6m;5r(3%5IHo(tKQ9?V*%`l14w{}k%N<$}LWKmC)KJEL0rJ}8Mg zq;rmKE6f?@(%PS2N^sG!RR@xH>R^w5M>&7uE3x@YUFkIQX}7XCS(V%U=>g2^cO=VB zb$jAL=jjE5zB8cPrv22>mKUUMnQpMJ4HKBO}oT9xx={?tT%6K*sn;=cG{F7GvA%K@3-VVkDZir z@MCZ01JcxHuKcBKPX)h6e#Uw}j>K~Oap(}w)pP$Ldg6BZ4QtwYeNy?2@UAf0!`H3J zf%+}tr=~+XpF=~1n;YB502X|E}by%qBAb^JDrPRyTUHq z9Bb^_`n4L46XxxQos&wK?@&-$=d_Re?%Fduzcbx2UpGR;O4%B6quCEcblid~^Xst) zwL|6isX3K_c3l;{!GHQ2mSaHQdC+b0R~9y1+DQmK&&3V|RAI=J24~J`5N|jr0P#2C zOo4Pp61x?@`>g@Zf7*O85@5m@`DdfM3+A2SPOG(F;rtasr=&NaA$Mqz_vvub7_!?= z6>mvRxz2G~`$onpakwM-vgspd-iV;wW^RsCI`fW=%%@#(nR~|GT`+f+gQYe12;!y1 z&bn3;`;%*d@~b3Vf3srTsR%U2DlzHy(E2@S4Ln{uT{H#`6srwn1WPh9&)oD4({HV| zuNPy5d!4z)8rvNruHMbA(U|jYSDF3>TIXYC#86*SJ6|}v$> zOP|NQE2)a;@9JeJ|7?5cgx%^Cww+^}xKRLTPj;+ifarwW2OFrynDju5H_;84sUA-} zSZDNf-eKf`WuHKnyoSSzl!p3|PyQ`^s6`nHR*{adm;0aIV@^L9U0bXXVO zG^lwaa=N9TR@A$;yC>}4XwbJ7$m(H;{l{e)WX{%DOJSXZjZZt3b1^AA5Jfa;Ge<7ASUd+QppCb?XzArPe2sVxjQ7000N8Nklw8$?K<`cCbxw)kCRoQ*>@vJ@E@A?hceLL3aAlv;YxVvD`cM9Hddb+_rFePA9 z>07A!Em#^q>@pz|R4;qg@cgcl3m$0AX!9k0rWDiAeQfh$_3T;K`S-cM#IEs+yoZbN z@+DVmzoVa~)sA!CUD_rbC(PNm4LB$;=P<12?m&omv*|0`o4LTo*tctYInSy8(8h~% zWk+zU4to0C?srevy^)~rDCoAuPPA{d%L{a3Zag4t^|r>;6DY42NsMm@^LUV|aHL%sa;eudF%0^9pNs6vtYJwh6~GGN-ke_Hb8bewmbH z&&#hZ_)2Vk@E&Z7)0kh4VZQzpI(CiUf$hh}-35cbYrrSM(>ZLLc3-=3f$J7;X0DO0 z88g4dwEkxcA2*kMvg`wmbCDQm@N*5+M-BAf5|8hUcgZc>n#r-#wExtJ;~A^^L9-Cl z{N;`><7F4Fvv0fFCc!Tf=Ib~St0P-InY`W{+tbiCUm5P6ut#SCgcQ1s;E|j4iG?1} zUhi0>8?d4e{vzMnMcu^1rrS*0E|`do|Jdf~1v_?D-=2;9#lCulWC(Lw!xfY|gazW>nC~Rx6@hxrpTCS%T%sb38t=7IbX@xZ_ zSZhA)je^KaXq!-YYLPp!{+q8mw;hw#n+e+`4tJQZCo2=dFZ7xJs_fGX2HOskjrq$q zQzYNW(DY5bmzB6-LxiMTU_A!a{~IfG|ovg3)*zM(zmbny8eb~9)2NLP6w{MwNP8zLvaTKk6Ry=810*fZTRXV2E_Y&cGIUXxIZ@8p(9K5R`4`7QVcn3v`q zx567I2YriztX>YS|GDZ8h!prls34;A4Z5z`8j}RJd`mkD0A(0SML+{uQ)|oXGO<*D zZ1Ux;19(TecCdcm6&75e*4Z~!Qu6^N-L=fnoHOAQW!SH9-dG%-kokLML{M(7S@l;W zUyJ+Z1q1Coy+OP+(?0nKekpfLJdSZ81fR@{q-f92kb7#Yg*rqW?l;K0{)TybX(3`~ zUfIQiZ z0CLx-U5G@{`X9OL#swYyUnJKhUn_g_8|H<$hkLU>)vLgVz>s@t)|<5TX-b%@=y~;` zrh`*c;b-0_$_P?7^D^6iV?2KYo%<%^15Vguj|JMj6?e)&C(nzZ6LU)nNWrB9lvA4p zkYw6f@y(7uc){w9^`koW`>rs51?!!&y*f>q-)(koVnXzzBUevVl|pmQM}=CA-BA3;<_WSrZvi+ zwzz=K+88}r+?_NQsc#|m@}YC~4t5{a+V8kNNv<)hOB=pUo(_wtC9DZZbakR!7#jEG z;5&p5UNFerTfar-jQej2q|=LKJ5hHlkiw8zHK_AgDZbs@@D1~$^qr}_s~`Qff1Dag z^zWG-yLJWlVZPe%wMaT7Dt7ja(1^ATNUT|j8C&Euk#5zU`yD0UsYoZx+qFZ_t%vJ= z(~e)GJ#~TmYYk-eGHLx!yK@0uVPo{@bO$FBr}TG@(1N|sGkJ%*r)tC3BI$5w>pOdm z&?;@~lavrOH0Mp&dV?8`-;8fr;kzvuXqRho&+}8drWZ&&4F<+KXg#BuLf)dW%g6lf;6RDHzQ*}%Kx0ZYN1@r0g;#B+G zOZ{}3ZoRMghFRcUePx|HLBF3~m>KTl7Tk~Xwg*0)Ccl$M@B-e^t%3gbrA258y$IFX zKcT(t0*#}ex;Av!K3xyKffp}M;=!7mGM^d?s+C&&xb_LI~; zUGMM)uD)UV_fGt7h3zLq=TIwf_K)w6($c@ftUh&d`38n>m{p*Um>r!AFF=#MjrQsK z3g3WE*!hI?h58C-@oA`s-@v_lkiP%;Qs*t0RR7xskChX000I_L_t&o0Ia(} UrneZC`Tzg`07*qoM6N<$f)s?52><{9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..60ddf24345895ea9444cdb69dfeb3abf9860594f GIT binary patch literal 11288 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

4k0Q6+5v=$Zt8=eT&6wY* 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%0A?yAC7`LbNraNA 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¨I;U>uZBBM0a}T}%$z<2C^PyK_+H-h1Ia@!oikyjR{c?_ar>9=94s zQb6To4#MDo41O;%IM5Ot_%;CkyK_+H-=p`$kcvm2^tFNBH-pgO^{1nNJhOvn1Q(LQ z!EOV@zl!)jnFsHM!F$xWU&coSPgenzK{&u|F!=8NXUs>rUn3r$?gAQdK-(Pr@5;co z0{Y*zU;lK`ZY!XFx-I$N70|W<`rox*|8&uAE1-Y6E%|2;XmEVn3TSXXwozM|fo%n} yl?mIXJ~#va9{>RV|7RT=o&W#<21!IgR09Bfq~APXH6)<`0000@a z94m*IL*$UNIkPzo+wVQPuIuyt-oN{C|8YI8Vx!h;&)4(x^7x8@E)S;&CleDB&wuoE zt}-z}bQu5bVFMpQlXM%HnE09gqoaAv2Qs@E@?cEwQ_{onHNtDX$3n6f_n+@5ylM7I zW>fI|^7EI;cjC}G=OvAJj_frV_*A)u_Doib6`0g{_n`uEsqOm^jY3}tqR$O&(dp4c z7nIb$5U6On@oilt8j3zcr}cBw2k2`W=dA+Y`O_-*ZGFotJtmy2?kv9^PyZIQ)lyN_ z7(|^z)8r@DUFnnb^?qKf%^`zDdE#0{;<_PWJCeR}h~7e<)VN?Jx5eclz3xiOqA$Mn z+IXBNy8x&6Sh*Suj%v_W(R9Pfb;34*u1y>l+(OYuHMZUx$d##wia0Ne(|?!JNkM9j zbPAoqM;Q35L6@LyyQ+H9HByEN*M?@?f2Yv-=7pH=q5%5Q@udMY(sHp|qZ{6~j< ztz*xEC)G5>xb3$-)+FM>eUPB%eNm6+-ewgHQ5hl61v_2Sza27l>TR$?(2ZkJA{lHx z)OYtzLyRD=$U&QPr9-tR-Rq6YUK5{i{rvQ?Wbu*#bMdRlD2L-oGPlf~Gh@Tc59%AM zb(+Hm;1+Lx@5A^yY-8V-Tq!?@Ew*-1)1J0J1wMDV06Yry-;Y9V;-;|ieOlwUK6d*R zEOBSTlO1#841^|b9lyME$vM{(=Q6hkS79#U*dnGbX>Q6o7PposZd!;W!F+EOvgJU{ zUL)8u*dD1im7j(qSz-=FHnZe}A`E4W9;{Jf25)+ zuOFENknS?a_GR!sm+m;ONxIQylz3?oFW#Z*NDpugc2Mg?@m%G*=JYj2MKAe@zra=Y zW`v?xjLMB^FQy-S&+Senz`XJ&6}V=-VrCRDYLb3khun@S;pXGu4m9q}Sg1p-u60zJ z2HD_`Rjf+9Ma!WD9T73&*nDT2f((;wKs zqL;WyAHUW5Fg}&i(ZA-zH zixg@A50DGUQcvc$3G6zg97e2dIv8EibKolU7v7_$nEjD#$P`~4)rmRTZ&+X4{CthV zeGD5+A2}6bnsPbDYq(@EIYxc-VDNEh=~@U`Cp41dDp#}c@>yZUMQ3twe-d*cZ(TVg z%bY95tM|a^5*p|33^_F_f){^9#98+gzaxx{{D?a?deiR$@=$pB-sgSs^$Jvp(ic~G zLVuEDZq&BK_0ISMnD`O7bshBJ;DqPP_v_czkH5@YkHu$n2&2I`1?Un;1zF}cRm{b< z=dtgL>cQ+-?aa=fU#WKMqt&{fjdTb~IDX%5nHFQR2^}+)xrDUL5zdf=+-Jn=ixJUN?|6DnPwYr^u^D&H!?A>J5imY2 zElfGk`p{Cno#E{YBRTT;^c6d!z}h<2W`VO539ZjGU1 zpBm!W53?lW#dYRyGau#w$YgXj0U(fT%GuZpB0i`2ll3ew#hB~iV|3AaviFYiRUd}g z){OrJo2hw@7l8;c|BkG;XMV>SCjN->lf{f(B6BB#>82si;?wlS(~#JZD9O>|iI-wTU{t8|*Gt+AoI@(T{a*7)m3U9wn_B+_q6Nv@V9SmhyY|zm+z#xD_nQ3?gj6rW?L{>Z*0Mg z&Br;2J9|_7!qjc59&`wyeRn2k~=<0#B4Cg5f;5pr>oRp+AKvwHgn z;7hg1YBWPG(y(b6y%9_31O13D*?d>7( z4U+ZJ8OW!*bc5=y8BqQFne%9!X4Z>qq@WpsivO?<%8}BbI#0kUc!wqBaal%+8|XTY z)qYQHsHpn<{m@$_G~IGw1aozTT>cuFbhCVz%IVbAvWx1gsXSI}&w@i9^ z(`^Z!jZ0~CB{*uFC(m}F=5xL*eejUwt=BF*Q+PoCp$+)bX^XMf?2ZdtqSK0+QKYRv z!#RHq36yHDMHHCZ2zgWqLnWcGhH_L;?BjDPcxp7&p{?ivd}*p|b-ou?&u#u66H6c0aJ$GqF+ouFq?x@NSGqbi(@wVlPjvz*4}DP4KYbsd}W z--8A}j#+x58|C+Z4jHPizwO~y_xH!=t?MaF zfHnCCvKlO>pEUN#$YGWelxSIBO~a}l)Hd9ZxiD_dJsmm*7tY`n54h<={GGI&);?W9{dzuFA*>kIp4Pk1vzqr$tMictlPHS)! zpSWog5C))VDHiXZ8Ovp&YO6RgzOx?JrOB%wX@xTi*R1|Pl-z682VN`xV%S`nT$JU3 z8C-#g2f#o@^4_jc}9rv2|?6qw==%adq zG0CmqJHa~gPuU*|kLLgkE408zfbU|H3Lrl>T1M~ZJ3@?&kxn=4n%0~4n1ur5k;06_ z6l?l%1izL8CA6ALB8tPl>d3KM`xvY!&~j)^r62leFCbC6zi&SH__-lwDblkvjL4+1 zT;Z(HONGX%-vm#n$;so45gDGw_6e*>>!gURp52=_LE_x{xW|3^Zt9x6kgsP zP4L6sp~fx91<|ge@VT9C*Hp|7VM|;b?F&pMV*6?>6HJOP)Pt{RAjH@a```MHz>W_z zu)mX}jJqa0?6Z*MSJ#`5A2-Dr#Hj1xmo-VS4Slqi9^wd!_Egdf8T6mTX8c%4I~47t z%b_RcDWv;OlHIDOC+63eObA$na6qFT^>}3BR9+_QUj_`T+1O29iqgYKt{gv+Is=2VZ@PTAxANfHd%7msU$z}u#K+}fNZT?k6!)Z za$}Fr=u4paOW_OmA1VkxlAU;=A}FOBu(V(3&E1!}WTI!b3GAOC{Qsd;KNJd=1gyRC z>SOPJkGmjbH+LKi(%Fvc34J)B`&l>X0(h?{n)ei*OinxF{yU4jTHW`>Z~6&*k_wm{ zmxK9=)03$18F)6_a{KKS|G1LgUw;@{wEUwOly&a`Kbc#q8cyxSh=?c!Qgd_gHHe!7 zq{7_u4L3j%y!vHgZoH%TN%w|{Hl`fNJ9fYg#jip#@GI$Oj`oskt~m<;%qzLi(mNA; zmLN6v8fwc?mC0TkqzL&TQTF_Tm=7tWspS~sO993K3m4k+tge|whjouE|I8JWcv7m}-+*=h!ZT}Bs+@X1>zX_i@9>%=-GY=o_ zbwv1B@1{rbjR3vWL-o^D= zrIb}@#>Jg^V*>1Kp*yG?hPVEYGoo-wGOsnShvMZVB`W-}CAF2>B=}ZC+i^^~wN9Is zGZ&2(1eU(Ya0HcLvP5+)8w-{slA<>?!G;p;q(_ zD?fN47tq$2VDC;Ig7w$SM==05YtbG(n*d8UeifXA7D;#?uPZQnt8-PKH@%K`X zo$tt>4zb>sWWZ+X+J*?epsE4G0sst{A6ZDh^|u$`#B&v@!(9{yy~-Z8@D&=yvIp`1 z8meZ6Tt%e0igb8>k9Jv=urzQ zq1sx}8DZya_`P)^^?e(;3E<+%71W+P!qx7Emm9N{g^a(Xo2rJhTwX@!)o566{~(>x1b1MT=8yg@Vl z#}gX+q^Zk&li*EUpD?e8D_JyGHODHV8CrB~NQh=`p0sUmqo%!O7-z&(x3~46UoJn1 z!ckHB&1UqepSun6gJgN}h!O(kg@l@?^nHa`^@k%%+tW9Ki8aZSd8@NLYNI9hkke*Y zN*!E%4EYu|`syJ+g?*Ao!;gMlZ1;XvSI-j0ygRe7$j4>zFvXZ(#+?u_Y_yRq>Ip5< zKgz&r9H6Ym(G!^F8tt~s>ySUPB6L?`_$0>v^r+kS4rP#bDfwMw_regG&PsD$pnaR0zar*cEp(UL3&UK3FG-Z*ZGPl#eJxZ*(+) zeGX7@CuzJog0Omf#av*WAR>fIk%1 z&P2s*_6d7r$9@StUgJ!8_5D1}9fEwBU-CT*&+ZpH()A>?!{$R#{lj;XW)&l8o(EnT z2!UU3lm?dHQ7u|Pz;IvlPqmko^JGA8*A$8Z5k62gYqH0aE8=1>QEM`sa2c%BB*}8u zZd4ESDqmsQYBu;&flX)y(w*;3SoON~5sgWDu*D)OE7cbp%Nt?P5@-uH7{*;6VKv zZ9e)n#SM&6LU+|0+tCN*Ql`op?)4LGS47J1y;sD&?==Oown6~zr#OBpSu@EC=nihD zkIja!&xVLx{J*k4{5H^Df)P<6Y+@(}k1G%7Mca2o7=&jpqO6EH4N{@@Lu76?7*I1L zv{j~3jo*A{VIb{8vgG$l@8cKT0W4q@L6SF_q)D^530Kzjykhw4^xXRdkrg&36Kn04PPVIcycwV@cTo)`Gq6bpGGtN*E6R0 z5|1nJn6bYiPdm-^%RjP?x})x1r<}}55w7hQE}Z(^aXi=3^wbBFwXZ1PB~GHybqa~xgj!$aI?V51+i>tab(LkB&Fo`wFp=5q;Z)~y zfjweJO0WNV52Wesnro^;lEpDVb=ZP2k#KzG9^*G0pCob5^F-yL47TUq_07yz`52NN z=2bJ-13bw%hDU@FXHY_6wyBD|kTG!s@Rgotjil`X%Nq>07AaUFD98rG2Wu5(K%t!c zBOI#RT%w>{U|VSvU1?B4H}7!0Z$Ijt5?1T{g)7_m@GhFUOInvUa}dn|P~oohjR;nY zQ0fIM)fs@aJ4s;)=dQ*Z<~Dc~4;fJGt0r=iRa=D~TFiM`{Wd4xQ_+_az6HFuU2*ZR z_eO2{l+tD7VA!*xYeORGneQ3`8FB*r!SV@(T6w?WP&e2 zi@z`(iMx~u$ETwyy~epGtu-AZacbaOH^JeFpWHz`pZelBMqaA=}0@L#KS zBCpl(^2XC}hP)JadylI!xBG)LJJ#Bru_rAl4Dx@;+4^!p0Wc>%e3NP=$4L}7Fi`(* zkJff|rY76_OOxOlIUbO;C>&k_`=f(?lWpV!cT(jGb`?YehPkq6s-Ftc;VvVYil*Yj zODWK%G7q#hFYF9*J=hd|IYwo8dh{!CCg#NO^vrZ8M2BYwWlllbFu`12Tz6*J`A@Z@ zCZRjTelky}+Cn4vQ3zf|0(i7J?5#Y}oEdC-=ux9{$C^~yVQwG4%kV>XbSUtRmf4%50#Xsc*!4hOv=3MDuHC$+n}gIBM6G2jMAfLpijN$!DyRX@q{X&7Q8yeVU=^Q} zrOqV`C*#e5$EHY%g8Z}Lf-_f`-yQuM00C@ShY>X=)%~0tnRrY)PE>4N6g;1jn%62N zy6}5pto#0tEO-0S;BbIarwiR`_1_wh_Rp?Qym&Ym93_`(@ECA=)?z1y-JfkD#Wi{m zU=YCH>uQ;BYf2S~lJFLcnC<)3O{ElLo08;&NptPFcr(LLLT(jLc%yrmzh)%V=B+t0jC1v#y8fspTVG*lZ(xm z)F_BI|4C<4J9wI*D;fJRQBR5|z^2J^M@;ZhgBm`lgv5y_^HdRc3l2_cE7G@m&7$kxO02@RiOs>@jGsG=Vifu z<8=AO@86-tC!RztAkjAUS=X%?x^joGXXARe1c_{UVbK-lUu?lE_)k=1Lmb)|?2^7! z-DvLkU)Gt`62l!q1yC|95c+6_L761e>WSNsAi$Ulq#1(fu0f3Tfpvi)N7hin&8m@y zU{szs5Se%m7BmthUk?tA| zEz~%o(Gkz0*0yx@7b`NQhaI?SxI{peXm>G`&{@7_z?t>Lnno^_w+-Q8-Cg$^x}W`C zuw0eCS6mqVCN?T1|7?f~fG5{{KVGlvx=KQD!-r`zJY&*SsuLI&*OLkl%?1CKly){W zAPsdBr{TD6)7?n(55526N9?;bp=U*#Z}-Vt-PA~7?-IHt4wjw3#Iw|ftv_=0AI7!& zDjK6)WGqwCNC4#gUlK6D;_s_H2Xzw;9qYa=+kRIfwN$b9K$F+xeK)4G;HVI?@;jxs zV8fki4^Egf3nfB+Lm0!70}Ujy*E!ZSAZHJTGXMtgs%K*ii(d`%Z@{=CLO0In?GT`3y){T+p|9}V-?FiW4B*V4jIm5V{C`+Hf5 zFX-0qFb!3zprNDnMyon7{q5*aTF{zze(O71%{4VJLID(oMEz9e`IuxC;OMglkcJxV zH!S552QBtZZ`eo7LYub>hnRs1nr5#^x+}Dk0VuUj)vh1c{vf##i$9`=SUfv)@#3Cf zu|`{)2QBOe<$IGS*_;7|I)a-`V*~VR;ugpP+B_1cv1~ef{tLu3-IeR9PK+fghWO|^ z%Rx4NN6E=O)Lv}0rx}sRt0&f8dgDX!o}3VS4>-fWeWH7L%TO@1;<&9A=#S&_3nQAITdDu;1>gqbCie|R66dzKEIy!){WFjX*~P%)MQi4Y z{8^b&-&<0b7g7*_hzwzuHxM*2j5Z{*5_p$6a4{ zMddegJLP_3?ZK{#jtTB>J@^QFZnj*}-2WZs8)Vf5EJX3hl0f8|=Q;jrSh-?f67=@T z8$Yrc8YxV-`B;FMMM!0i*v-*aM>A={t zRtzyZvCaI}f?dyK)P-~cUCU{M8{n)u*wvHp6#oRPG}>s%rUKW+|DGM}oM#ui(AXsatJ!#n=DM&;L^LX0Md4 z+ZxS75$k5C2BY4^!-KMU^Gj0Orlrb!O@XUWrkr9h`-Itx;@~rA7J1;`=6l`=y-e?LstEH} z?@u1^HdeYlCB-GUq zqE1h)RFI>;+A#PeC_5G>s>C)T2Q3mm($@zW=*$F|)qli1ec&97f(&IB5&@J!2_3z8 zc-dhe|Iyw9Q%Z+}Kx72WexHeOK$(P(w5fG7IsSycwU*+y-buiBQYDBX0tA}5e+QZj z@63tBs>hRGWLR1ke3?(@t`bm&y$DucS#>D}KsL;lRbiNBD@URdN3u}Z{^@3o4R<6?068oc;n*a>6rSYA<`u4^M}>6nWh_=I%^lZLaRO^=t-1dae+@k<*pgg2;U-;Yt$e_`r{Fh1?RUzD&< zR}ZyTDCMyTWk>1Qf+)d>%Z?^<-TAUmgExatz*^alxm*rg9%MmpJ~-!wi+9qKXg}$; zFGfz!x>%c}DdcRRCp+ugG9a?3V{tb4il)1a&@n*nozEVb))w2?@iqh1lhR` zSf1X+;lPe)=6V&w#B&?t1ZVfWBMjyj`g=yD2*2Nr zcnu4N)8O!Rv`V;c^s8-2UadmO{YUu5+RHd>iH!sbaIZ959_qDchur95VH@zUlE@26 z))*_NO2m8_s7;(85^N_-oH4r;emO#c@(MMh|G#$T|DG^LT(z4N%)OSs!{88I3Gq<5 zV^{3V{?{}Axc`;w>pO;n2U{}AHy1wZ+7pVd=0B9A8igdCW)(z<4 zTjhth0*G#fWV*G4ybdFqmw@6Jc{ob_3nUW~*^e=uYyyqg8~B0A%Lf$+r!_yku1Vyb2;u{c@X zp`?X`W!bH8dlwTwGadKSaT9UUuZ~AiD{<0Qk?-4A#UlOBih)kflZUp`kq#s#M|Ceip0ra}^CSoyJoc!2vlPxD?tlSR^%u!$Lcd6m>Nf2Jan0IY` z&1IbmxaM@E_qaS4fVd!m#3R8Hm~C7?EjKSiq2$<^Z}C%FuFUTqO7N@|er{?I{g|hn zLW+$#Pd}fW`IlCYpp+=PAI&M-7z9Ej?VP@4QDLykS}PbCCD=#1d+pC}b^%A+<}O)&k(vDQ+CD+(muILW)dikgzjPG_e;f{&uR`PGKIR@`n(K& z6ub!DmJF0jy(n>e7oXT3SNh$!^%%6d%BrJzKpd{i^ly)&ro-1nbjKP!coix$2i1{3 zaP~_R!64>5aK{3>@;6c#{21#94s!Vs2r|8ahp-l;q>49*=h~lpXWmx4TN_sl)LWI6!9gAMriv-fkOVz49}2{BsU` zO<1+r@?uM{6ZJII)^ADptX~MMgXi8@a8*Y0RZ;tMp|@j9PccgZV*8|@49FDEYp2G; z8A2+N1wQ=RJrwZltoKkQguR-g+7rM_12YU6O_Z$KcxFK9(O=Q8fJ9&2+Dc$sm7KaGqNo7HjlX~G2YMmf7@9HE06mw*p^p|!Do0O==59d#5AUSGa zb%tNT8SKuH?k-u{-H}WG(DeJ?v0k!~Sb`PrW#y-qZiXq(zyJXe0)*fd749yxnE&?b z$7&K5V^Ma;0X=Jc-=bmG+C_Wnuc$A#@IAeO5mXT(qrqOBkHGmgVb3w-qWILZ7T?f; zRKj^eHy_N_uj(``F8>rig6jE`c@K{ZwPsfz4S z{QOA|-Rn0xwHwUNB}*zIL+c9MQ^JA#!c)Cib@rO}vHOsCYKrf9Nf0(cF=|3eRC=C} zih79oF4$& znNxgXZXsuAJC^YeFx0nQ+t2pE`-! zNyVBDbtL)0-|evBRrUM{H)o{CYLnNIq1PZ(_A|LZr24U2qgsee;PRgq`)OKdoc8v( zsm^)@@R_ly0j0AM;`^4_6xP77?WjSsk3Phgt8uw4CrMHDm(ax>h;mNhE#g{R=?K@w zmlxsQ&SSS;sLbF%a&3fOVmx4HxTGT)%zovoiN+}SZ~}N6%Rb%sWSXm3POmQdy&v@U zYS;HMUaqRcUqnjZ8x*;ogZYCzVs^Qe4cU8RSoctPJ%^IUfynbKuu!rlV+qFir9zD7 zOv8v;?#}ghKpP^M?NYIw0HE%6cZ9%rP4rw+?G858`lq^T#c`D{sXosSADp*vlZqvI zo(pbg;(6o)s41-cZQmudq8_5@-3bpis*qSz`Jz!c=q5^G^cf?WRnb-3LBCdDW`&9F z{dH)g@^hSOp?}M;dbh5UxPW2A6QNYYpG^&hrd1Hk*KwX(-Ko0ce_U7LJhkpFq22BI z-|4t@?!{e=2zo%@(V7E~1gqbeL|1eOqgp9ICH@fy3}}zCH0{D#EWOP0>00^F<1oL^ zl4dB_gq2HF;72*=-HqzLe|zYDaJ2>4jHaFD`{o)T)4-HqD^f46WcNfXLk6B1HBy-n zbH4}5WX1D0LL5@ys{DIlYDjdX!3#)E$WH(FGGARXC@cqGVfqc(sY=@br_3QwmQQBR zm=~sXqnRCm7zl10fCgkV6uVe*ptas8zDu$@9oOW80JRc@PrlG_u1TFt2vYW$;rIbB zxM%!RnjkO{#5wP<5G%FHBJ)efr_#ZGw347OD{DKYcw7k|x?H{N6uq$c(~dHCZKk}s zuv~Bo{*lx*9gCtJyKXgo(+227h6Os`jEd0R&RL5hEVX3e7H=yaE#`azD%Asw&3B*b zZQVA)UfJImrBOF0UDsX3DZMr8J`ng5GCv@qR#yDV$bTl-6QI+2Qbazbd2P~KxOZzU zF(+beD}MtvI(s%{$C3}qW&UqQ9?zB`ZeL(OlNASgo9_R1eJXuh?)>cqplDA*CeK+r z=0dOfT+cJkpZ?P8p0u>VsIhvCxj5>+k=0E^P%ti^NQs!Da0V4A ztvy*sq)GeR$!D(m-t_o0K#1?w+R-0kqqw?nU2$Nrb%3md&`N)|_z4<9$#4j^^#`zc>F)%>Fg(RVOZ5FcK>-MvrWlrFpX`P5^&xLZiMh z=-bvb2h#fI@5GQYXXGSaQfz@&PZ=N-U47s#R~Y-$Dq!zj(5qaaL<2{_iWwrWhwsdd z`c)!C0J^X#R28!w620mirKAd*0iUcQK}FiB#5~@q3OVMAs`MZK8ui^k)zl#^&IM<;eBwZJSI%AujfYa{`T~8Lasl3Z_ zjkZdmg%KNb#naL0&WzF^e)$QiWlF&s5mY>`ECkG{3KV{nYhi?4sBh3x2w^D#eD~)$ zkwhWtvU|445w20em)C_r?9&ti|B{~9eZ_iX@CwRrgY*{-EXqZL_n-oMxY~p|z-TSq zKkEP=>4vKcXy1P@7HFYjl$R;F87$KskokaIlZ*|l&tKEb16|mM#88v^Xmb+H($ z;oz3X%1R-*68w_`B6Blti6O*V5FkI}kv7vq@LTJt$7*yU>+(+>h&+B-T2E~8m$#G8 z{*Xt(8gcRlen9_+|Ff3T*G4n86?k;GLH7aFnDeJUny)~Mi?B#Sr?c69-s(Sw6d6D9z5e_43-zS^{b{#1`N96Q!pWA|gu*R<-gs3!( z{Lp~IdGs3+u*l)pJg7_%a>$l_l|8-B(9sP%tGgzKM2 zTq$WmwSFi8RBWImH*L7?8wuY4e1Oy&0@Rn!T&f<@c@4W1$vGlFeZna4Y?KtRq6Ep= zv1=#&d5d$4X>Q~7ZC<}Yv5i>qJmsVtq*#9pmz@?Vl%Ph_-DYNYv3U5o{-%RjH68~YWp>1%J(;)8l1 z$yWY@Ji(77%jIQCy!l`s8BS)$O1QWjZ+oXW%^sMEmHtS-;8H)Pf}{MjKhzp7&%c+^ zL49{RCDpl-#b>@hJ|4P?e= zA3}&wIYY=!5oD@Q=b3tN=)^=ANaUMroeO;iq@LOw6IzOHk8-+@iBPV?p?*`yJ zkHqAjgN*?d{3fF*=&Il7WeSg}q78@=-nS3_E=M5P1@|RO<{n}hb3+IM! zr+mj0LhbqV0!xK6B&Ouw_TPaaV3Gc^!}vxq7^dCqwIvhq!ZqFb7|f=Oq7G`a&l$~6 zQraie#p5A)3y>pg_f#15C_#z+Arcr3M%cIe01pY^S0W!rN^Rfij7Ia%=m$RR3e*g0 z=29l+_3>@K2F(i{TE(^P6XYDRKVYL!==QmflZX}l=`(&eXm*A$V0N-ncOy#owMM9l zGs6PNiUJnEazt_&uyC9HM_97!hwq%-I7N+1UrIzqWyVH@Z~X680z-Q(G=M*lByKFf z75S?x7@&&(LhxX^#qY$=Z>DPBLOa++q1#2}U4Z|KLVvKE&a>u($0}GmGUu?~o#WB* zAD)Jqql@DJ8Bq^|bt$bYXtSA_{H6f%SyE7R)BPdsS$N z{?iWtJ5J01ln%-bqj`(%_C7kgNIrk~J#HWWe|D-<%7!5B{fOOonmgBy&M*vRxNe($ z!;e}v7Nv+)Q2!~>pQf(~k;}~^`v`-T2z~n3RrAkNl`9)_0m|wzsG$!Otm~W$8UtT= z{02QPx^{3)G1-P}02N27WnU|Scee&?<{DO zqpIX1#RI?HlMsz^scPIF66`bXos4UV%ExnJoIw71Jymz0seuFTk!^g6QHle&>QY5b z_~69{gc0MOX6HgF{12Bkd|ei7esuSui}7-4{3*?0760yM3ZMgq)21)@>KI;rXwC{A zY53bU24%VavTYklFgohjo_u>Ki0Q|EB{u3SazNTcSKfSGfAU4P`OtlalK9J7V zvV?IevDpb^PJ{CxKu~g)jKFmuPz{m^dtKC1Hxz*n26~iGKN%G(Zap!6_2JrBaAUNX zfQ#iZyw#)v5IV*5@XkG|AAj*r7M+7r%R2v-NWdKz)MfMDVeyWb!?1wdAA!s3vC?%F z&)L^LtxXtBeUmsie{)nyY0Zk@o!>K~J`uK2c^tiz6}b7!H!k93 z1m%3lCIL2&-5t^Ap1g1*isF(#nD+~b;!??lrc2p>-C-P%mM&eY?o!(BBWN9;oWlNk zx!n~vevE^ z>M%@9l}o@LNleVIClVC+10cZT@FWnToi~}_1~hlagbtzY{mCgT0L;TizNYZsMiF@n zrvR8ZB1w`PU%{(62FG+sQjjayWfXVDm}WUpT;U)<+e+34C!vDY7lwM;D0`~Ob6-{p zr=R9x>ir4QMTX9~Vg=KTBr#2QD(p60SvZf%{%7GU(uAM)W_Zf(&wAj05RMgHzLi-JEXMiy~>mvc@ z6l0#M`3^L7K@F1a4H_u#R5-h3lPF_7fpR#bTk?**J?>qnJ&;Fa#bc!I9ZmJ?7~_0b zyhdjmlS#m!u_@DI1D{(JN+t%hv$t7RJ2VQH_RM&vq1%AFi9YXpAf#dvWh}^*0VJ-BtVq2a3+FW({wO}a&RTaymuu# zfgy;{kqM63nmucZYGp^)h#kQ|8@+Pj0p^TzUdh_PqSie0454oX687g%A%F7mO%$_8 zyT`lEdd5v?_|}g~%!td%KGZaK`tQ)IGG(aKyW~G@*y;T#D7SG-`kBBD#%Y0H zlVYO6$zMm;yW2#j_WkVztg#dVl2#X3)9fgJiwQY2!Z zN8h-^hUgxb&%Xyj=>I({J4rp50>&b04o}dH0w@sFr4tOW^!D82RVnr|U1tCZ&U$wx4k62I?1txP zHI9Vt!fqj6;1b;U@<8!O&5IC+XmK5y6SEex=eOVsV>obz*bW1QY!bMg0NH0;j|zyh zkTb?_#o86Em>47^8Gj&G6UBR5n4O<I;F5Ledeyg-+wb?3RK zkjqC+W|G52gKVFy3~X9ieW|{-ld?0M6VTYWE)+)?>G(Rr2oS#R0EzFozkU(|{S#a< z4weP6w~n>FF&8d8Ns9wZPA8QRK~oJ~7t?RZZJvvlh}o=7Cd}yE7!tl+(?2I!9rMfF ztx<|Jq*mY@7Q>|e)KGI@@<1t%CS zf^-6+IDE(2eW1(X3EI=;4;hCe?4@I%a13rmb^{0y(c>4c)`QECk+p&#(n_8beuf1{ z^%-=!<#H#CTG@`9eAL1*<86com#Z@aM6CGFvKx0;NnM}=w5G%*daK!AH&LW{Cvmy(-^b^oKg$S;@8x6rGS^P~;$>wUCXMo#dGf@{o+w+Ss>kVU|;k6wcsY?j=*T=WevX|PoH#`9RSAnk*5b)&(kL((2Zg-oFK%N>L5 z2j!Su|MiUCN1u=w$Xi@vEJKahFe($m`xbxkm0c-q2VF3ykndjeV5I;&g+u>~P>gH^ zcT)jqWb6Uyc>dPy4Q~R>549*bXj3dj4icVq#xa(j@HoQOV_~bH-J{*Y#qy*#JC#TP zo_rP67PEBb)G80gu*%&~HtL6i%&Vt-vD8{Ah7W&#Yhor>d{tH35uEUSSD3g6k7V3t z-)eNllQhPE%{jbkGE5YBZJB2&AbQ8-BH*43( zk5|fma=E+u+`IV9dgl(izmax-hKb=NU*`q;Fbu)q6QGVYlZLDBxSLD(nCCk8`Cw`D zZj@2EHv@;bjZ!m3a!PI>moFNFbJ&@~6pk6I&)!uE^L8n*QPHXMapkyW34;~JSU*>thm z@HTCGloFuvtC1!J$Z^U$D)79@hPF!^@60bMJS&LbuMdxmU!E>3`pkus_pI)3g3}c; zEo`6S^|o3&#~Iw|&RJ{iiQjL6+LR&qWC?!noP~9n;M-8(2OJ-G1H zf?zTu*Vpa{RMxBP{`h4Rv5ua*_>{Sa&j`M5C(3l|muLEZdDq~FSlb${L5Ye5vrrr8 z2eLgjlbQA`KL#kE(hNb76#}3?%ieypwvu3Xg1~3Ky6as0Id%(9WJK8Av&E!hyEdFi$`2e;Uxi8IjX`jUQyq2ce<-X#z>i3Bd0lCK8yK(ZrKZ{q zJIQhu`xNhwX?tWh0Fm{&y^eZ3i-qWyhSD%6aDr{Lo|H8J7ADDX(Z5Qltq0@aayffb z?ixV~??*~V`r<~QStKaG6<>XuRC95zeWBb^O|zfRFW^_MF9dKk7}0-uf6lu8lt#01a(ZHf%kORoVzH$0E=Ava z{>dY_s<8=&CM)t#c#*F zHhsskbE+VBY`9$bUfSKXJD_6D+-7Nr1{V@X&?12a!@y7^x1PeY7vo2MtRyY{z)w&w z=h3ZaSCVFL#_z5N%<=#m-*)E^`gWaiAdY-nVE>?abG1qIJP$_2-X?(EkJB4NTJYp%P&G>|*#g-OH)UT^gl#sZmOUO&@&#nS$ zJFo8rhrbYnZ&!y6O{onAt*8{Q%k}MZ7b7BrECxq-d-Alfrn@{x`O}R7dmq`G4JFJ4 z<>p#1+uk+tB=1gN{}@=$?%?%vn%B6%^1*IwJKz7-zy-WPM4RS_>ArVM{-_=C$#_~= zdQ7x*lvp!Zt@nm91@wj|mhn=F&L)nctG8t5(tODwPV7RE>dkv`eGDr^djA8)Ui)Fd z6kP+;vOj&^nW05`bKG~ZbY_nf{q1S88GtJ|BW?l35MVuW=(N~IHXxRPiIoS9-+(|& zt-e&XB9AlR@|QHLFQhM3vCMC9f}#V}b5Iq~gp@m{$SLoe6S8H1ud==6o2r>HRk%;V zW(1#YyX}<#id_v4lOQ;;3wZetpo0WZ+z55%1cJDgDzNpGMPHw$vyw^JXpOY@Dk zqJOeEM@IX>Zfec_)u=8vDSy&KFeXSQjyn{?nJ^vEV>-58U9dU1u=Yy>F@eGFNmrWn zw=nx@P&`GxL(`iT)5{2L+;|Kpzndq)kgyF;3K5?JcidsUMEt1>Wt17!Lx zD)0HTIP$$@%tdk8le_=H0`8M>>^|Y^y>r6v#=PvN#xfiWbe-S<_JJ$A2)=dnTLgWp zq)MQ@=&IJo@F^ubk>7(N*h456Pz0{~10}yHdr1O!p)u$m*Y1`lGxKnPO;zZR7-j^FjKu*L$1x(Hx^3;0K%jz7Yrhe zy*nD}D9wm2WQ|b>vvq9`%m^aW6J0qm`#3&O-{LLjm#QIE*ks^fKx~eMY=ud!5<<67 z8wej~Fq38VvG-gEKq!R^OfSPvCYu3;*nD?uW1I=JRloD&9WG7NyStqz(8P#f&u z$Lw8wWf`PZK=#v>SFv@@eAH~Yj?fP$<^{#J3)hp(2Xui%^RG5TS6Mn&gZE1s;Z7~t zlHid5`$PrgR`Ra#*pn~&MJ-QtupU-6g|48j2rGs4+)2{CTYI}y7n76S*j2t=O`4kG zMgvWlFcgy?3T>(eoc9(X2D29htQVkckPIgZZ3=>{LNL z#cl=5)KOQq)Q41r3vt?|bo7bCJSm?^ursTa{!1ii`;~m0>kdDdpwnUk_aV)Sw9Y=+CEU0@qi3-q zR|7e{yesSuCqH#j4<{K7=&tZ44{J_bpYNA1E;6a8cOE?+h}ax9cBl@14JTLVz0K_f}=MC<$CYyvdv6 zEwsMRu#Tbx>fAo32hqfp{~r|sCTieBHBBEyhR6cJ+omL&${A6R&i}92rZc23+uIA( z@5KvrsLr}s#BCmoiKh?HQr?!y2`=glr0T+iF)`&N?D~y$k>w1} z7Pz7?SpY{lwpEj=KLbc`^@moyJDm%1FTeM`$Qa_CSYV|MSJckSe1;D^mervFkmp>Q zH~+dtW%ldl`D$`4=mNN(&;}4J>j4?0^9Y!iv63V*K9*OXIt!Z`+{sEvMkmg5X`D^|W z4I0NfD|vFGaL?@z1E^~_{I(DZ7Hgb><}e4AE`9r&Vr+ii% z>9On#-lN-ozzM`O7dkOZ086)o#ZX8+3=b%DS{y=<+V&6~r`x~<vOx-l#Lktmb1oO@^6;r^35S+B! z0bf-&D|Qp~kc=*v=AKPm>E^>W=7Wf?*qNNtcLLH!rXIcRG*DHft zfhbL)aTHxe~0I=z&ra7=pq_+pj3po3KVt+b}G>$ zb>m_W)2?F+$oinVcbzsR^qoh@fI-{o7@Uj9*qFPWEn4`5|3Y79 zjx|6$!J!w!ZXWWXs#tW+TOzh^sF3jQ_vgP^=Fy~=@op|Pn}oQ0s_s8Y%Td&Z{IxUY zdkfMri9W~Qd`pXlzubEfLh;)gwx_8RtZ-?sE%DyW4QyhL$0Cytf8c-aHk9`8GV)~k zWVX$@kSzdhEA@Y3u%V}NQJP%&>!!lxOPC>jWulFPoXNRRGbco`;=VD6l-b{5 zS|?=QT2jj;a$z>7+%3s*fTE04v4)@1b#ZNKDDZYE4G%s{V}z=>g1&;h4HzPSuMK_^ zlL(8C8!^4x{{TnEMjtR4)-<*$_IRgKFD5T(x*z)b?Igf=M(_VJc9C|sUxj+5jFHeQ zB%y`8J6q=87;X&pAC7;`GSSY;!VHdZ z`rM^#AxJj_R9h-T>O{Btx)%YRpkHeZbqz}At)z_w0f64lKah(oE>scShcu__qv-iZ za$s&09Hi_M|((z`vM{kdYffXxP(gA>_>lf z6^shHE8;P&F(T@}#i_H(eS*S;d*Sx9D8akGVTbFd7}6uVUOT7X;3zxy`JZVl`j%jz z6b;b}+@9*5(kYh3KoF6y`t$p!e>42(&d>F^uV^xWZhSael}6_L|Db2g*fuwp%fAyZ zlJ`PhPz2Wr3tXs@kma+v30(RveE~ z_?Re?cxl$yQIxo{`J9PGnB%=Qjr))TZp?ib0=DL}I>!x8ZI*r0FI&y+C{v8`%QxUlkVr|Y-;$4o+HV1Lq?^JqGT zANjtM2+St@{`W5QoD~>UwVGg9eEHO61lxYiQY$Z0Sf++}!)>qWyGn9ZKc($d?%tFZ zNFbJnrupC+pn)^(<$uWeV-_t#R|JSZo;hxsg>w5?X8`dw=-0{@oE}?X5Fu-Iyvc`; zS1M_etUAiyJ(6DM!tlC(E8H%UMD3sy@qePJevCWsN|mtx9^e| zWb*9FnTw#17TT=3y{QYCPT|Bn475$=#|2KVc_GWwj|XwIY80;TgcDbxvcxAo^Le)m z36G%(UQH^e1xPg>7i4$bmd$SZt|R-5ur|KyxV=5=HS`+E&2`vnpxi!?V7mX5;H$Nz z<;{9jBO<%|fBpqx0XWM>>H$n` zc-W+tPagKV4sie8t#wY=KyxVuk_wY_f8 z=~1@dFsDLYnr&>3ndkbrBqS(@*>oCPOF=tff%=r_@%e-giQH zwdZuE|7KqpdNO)U`>tYW{*oZ5tx+8o+_XYV5t~1n2S$*`2sq^>06$ACkOP@!QEWA+ zkNBJV)ouX`%DGPD75LAK+*td8ejSDQcgbwu;iCRt`>oB?b(5{`ybR;U)@HHX0WK6Uf@VgBoXLj0fW_w?n@xhwM@FW*+Do}9bw~cqle8fchJJL!P?@p%t4A&4{j4C!`Nu7Z8e)`nXGe3m+$0F*I;m!rC4uj?f@uIWXQO z=MFz4$aTVvLU#gc$~`m+sVX>V5x*(Z#Oj?PrBH6Z4LRyGTU9QGy9R9Eskqb;N>sEf3N_b3}gNE2JN_wGe1>|i+p2` zhIRjnWE$qb;x{An{YvIm@y>J*%f4Mf(XAn45vBb%QU+!;5p*3QaBLOjHmL}UQ@({@$eIR1947qGemgYw_-`$%$B z;$Q~K+M0|YTwe6V{D>~ioh(P2R3BLxir1K`(}H*@0|`QkXL(1OFbL`_e!7qGGa}o= zSJf_(r-V`Oj~sJHYbs_^%@5llRcDFU$@O>kiCy$$%t}#k`B@i=(}*i%Arygoz?cm8 zmvU7MU0v`W@YDw$^HjJpe5)eiRowANaM&fOXqR_bLfURc!d~A{%bu>UXHB)A2w*j$ zMw6|wxF^V%QBhzil{6^toF_WDAQbx{g87+x2Cix)hvgUJ?kWiSt@!*cSa@ z*5(ttD#lE%2>85n*F9@MV`gJ@*?+&JmM27YY4 zOLR+(Eu+FdUBy23ztb!^5I8v3+-P2cCTt_wF9v%FQhYjsyb_QvR>8#pT*LX0lX(O? z=d?@6(Ksnu7B=0ttxvP4(L@X%{oEMLa8x3p%@I--?1SDJg+bB!sQiGPzL03_jsrg^ zgk<7X+ReF=_y259t$r_b!&wdBnsDa<={@Niz46W0F>j7z{raqav3odewcZq!7VKbf zyqMMfsw9HZypdY*I={GShz7tS5`2SCv&p3E|DYuhOU+t9U9$JxPPYI`${c;;`w0|h zTzWRu*KkF>WnN`WcAHXr3%yMPq7B^9oJ}cQA|n@&YoqX!JT$tAmIMD4wH@dVXaqxC z=m579;&MqTq$>L=4SMoR@5)&M;|gd?<<|Ww(I1Sy(MYMbqk-?w6ygEL@CCeSwm(wo zXB~f3+ocH`07Gyd%Il(D*bv^C;lGH#Trgl6oU%88fAQdo1GqTR{=(l|YC|#pa@erY0cdt1#;iWmxr6rpzO`9Bm?IOrco;fWdpo1=P#+uD{-lH-6Uf%*Axdq2}cI5T=4d(Ulc zV7q>$z;>G}@E5QFPIZPG}%6v3F*>{L3%q0j!;A{sxe^|Ep+}x;>V3;E4vKI6f~C zM_+ubjCh3w1vzd5Y2*SDBKP}((9smOhny+H#smAt^f7sWGlD)c&xpn;)}^j=Az{Ax z!_mw)c`@~Piv9f>>xWHtp!W%ZH$Q{B1G>ecEXdiIkx*$#nryS--LHsnpz<M~VR}dp07j-sJ9^eXd{FX;wMBj>g#T$yfTS9-az>Slq7m+Fe8> zY@4|xxW$NY-hn(Y>R(UDE=)q`#L~6elg-d_$gutw#ryAMfBKj*ymG;Sp`+ZWlz! z^>XMx${or8BSiG+*SFIVDIv4{?aKP+0dU$f8O{oP$VXKIG`GFDPnJ^`hd{ny7V^wu z=Bci)gXvK3haG85EeJ#x&#MHgE(Z&l-BkS+28q zG^RK(_UgVGoV4J&zr%*k!sV@wCs2>lRU1a!Z9EU*R4$=$d+YSX`+t!j4TNX_O%#Ca zBxTkHptgO$0J9R(;zu(G3z%L8)3x>hye21c{_%I|ysP&_fUk;O5ok>JL*HxqR zj&oaiaM1KS2bDa5+yrWM&UwAAg#r%}*WL!&+tdFv?v`qm_K^SU@>zH^`y!T|Sl0hE zkFO_~fjkc%RPAKPYlzO{iPY*p%Y|l+KQ|sMlOB~jcQu-2sfz`ePU>ZK6?By1_`$Q#^cELw-G0-6VNp8WIJuHMHv<%_i!?A%mmx2saC z$F1O=lOx`DLc=kmm$EwWU?Rau>@aIL_m8|C2W=V%aC_u6tsZY{7jb30%*oO;*~}Or zXhMvhzz32V&3S5*ge`K`MXv)-MxqYz72Yk${#>^AfR zKQXXe=x?`IZc3jK4UWyld%)=rPE?wXuI-}m6B=UgKhpA9?aA>-nki19-*UI#XB=p7 z`cm@ra{GKtR-*7|wp>p&n{6eetus?84S?6Lzf?%X;KZEU>(Mi6Wm-c~0e*jkx0%7yH)rR}*G~5-F zS8q&AE)LqHx7_w%x0>L-Eio7Jf_iS#bK8rrn0srJGJfW0nMTo)t?+srs|BfM_k#6O zV=_V=aFO`3km&tU=n9@pQ(KxDAZxH;quM=~=B1s%GpLTnO^F_VJKGE$jc`JBVNxp0 zY&b(`EKv0Pp4n89&s}>r#r|P^$Nql6y8cz%`22g4^i1Arr^G&s(>1|{`73SkHzm6g z1pSSG)QS>G#@FFQ@#1Ssp#6G(jz=zV1_&$JhZ01_Q-_ZxI@7wcm=MQfkGao?UI0pg zO2;{o2T}9P|d+Nv0?^EIo~1iCnO!%ZU8n3)BpW`+|)HKJWS{ zowI!c-E=hEmAvGO7v4FgALBK*aYT~(v+;!``o!+IzM<=;vbsz=aAvY0aP;IM$7ae( zV}`Gj@zY`y+))$jc%~tok-bj`Q7=C_2dsZBOBSSO^etpf^!`ZEw=MIKB#LQ1 zZiRb+CIt5J*P^v`>pI#3qzGg`@7NpBAdtwKIk!(oZh^6kWj{?B?=jrT-KE|X#zKIT z%Y4=|FsNbERcUhPNU-wKlAzCLkgnQgiDX4@c)(MaJ*L>Pkf0Kvw%1aB7`ziY_wY4u#^wq<_PF-4Xj%+}^=}M<(C)Bj81iJZP#UwqIBthc)>n4ShLEX_RlVxV zWT0{Hh00u*r>2Ha6K9vAwi?5+86Au87sKv#w)@QGDjiuAuVWYpdIt!xKdcrGRjUU2 zj9soOC_6YCfdf2<8l~kd>*hYTb<{&!u^?^m$T!Q+M6P&JGW(WJPWD&KFP}MMXJ=N0 z1B=Xhj4wCeIaHdwU-b9W{y!~Uq^)OV0_W6d;*kbwJWIcGdcASroRCi+d(0skM%ABv z)Cmtqy#bZpZOh{rFbysWa1o-5c)i46nr_%tF%q;Csy3dP?6e2)fXQH9T64{pqJv;J zxfL%f65kE7-Q)nk`}dZOFaUNk<>+geUl1l5 zxi_ziZqz$C2DS5#?3)j0<6H6XSLboto~7|w8nYK`l9uwcU>Ffl1YF;GL|*-|U0^9h zir?d)y3<0{E-h3cHld!;JKgO0dWv6QhXSV`{Y8kXW4c>!*CA_PPlg6okg;R-stryI9TIn5mppq#At}EKQm@KR%&A#v%-;MBnD~b|V}F#Pe?BuBC%^2Rb+$UgVCQ%|QSOY!J#*@_I3(ghfUm{FhTaQX zpMj87MPI6>cH#wI-69^-GL;@AVqKuuZ28g6k2zHzp!uRZUdz!4Ol&{Fgj{Q5;*0Ew zfvaA5vy4MK-3wgGPo#+MR=?9GhEMOms8!H(Io2O4Zh_nXtsvgXan|hS#dBi}Iv*#a z7$Qy{X&$9uA4i6Bjel~qvA)I=%c*#}r1{aJt7z4Dnl+b5lzfB(E}>WdZd&MP9xt9+ zhJNH&7BeE-q2I(=Yc!h*3GC{~5=L&g4w!fMy2q9FiM{_>$-#VF`0Z9M^-zfqV|N!d zwKokVq^#vVH1v&R=+vUzs;3rk8++nm4yVv@wFi9qg*PrM_hO1?1r?KhZ$mS+7&-dZ zb3^ipkO+1Y2hoj;I0I71u3dP_pE@sdw%0KCelE$im1-GLmkc?pP97oev5>-uX4E{n zouRCjz6d4=kv6o7ic=IPgTIf<>U^+~IU-b-UKjK6x{7!*_+7{_yB>Pge-z@D&tu6y zDc9luhsfqt(`-r(70MAfToXV#g*;?#f1bZ&U37NODZho6AAkuRi+E1UvTy#>`gfM5 zf(_5dn!{G>jSI4=eaP$iTE#c8j)ZOX-0K5V>ZXlb=h*EzBy&q+b$=K- zTAkuTUXqpGW}e#($z&47kpe^*F9`F~#YqO*tT7C})3oIn9!?(!`(~MS!Qg57TsOS; zC#6MU&4W2}ER|y0zb@#!VqEb#4H>}BRGx~RHkNeGEfwL72zxf6cUk|FXKrPH6JF%9 zt~VkFz82Zll9-Zwr(;pY$xF>HdlZpl$y?bM6_=P&1|C(^_tPN2ai#WT&$}0|56k?l zV`82x)o1uqe&(f|l!BFPr2YV-_jQPtz-!o=!Li#sP-Zujc%7K`+b5jNZU!aka=w-W z8Jw<5-kk!nsBiH_>~@YS0C#g@`iY2WQmBPEdmqXhMz;i*a2-g!QpS-)CV z{H~DUNQrFBZJSyG3`lGrC^*ofVyNGJUYzQM{e+kC2awe)}>)F*ysUf$e(gP z7a{FI<`$T&Xahqgw;}OLK!`am#GT<6hp&@x8(Ae{JyeLNudpqIz6d|h8Ox;z)q>07 z2U4M~OHrKy?v)G*F8bj%%yIO_wwJkn2G?1!*;XH+-D=soON`kyW2YfVzRWR`*hN06 zG%Z2_l@XI}lM$_7JAL!+L$JdSXT}(G%B!+wESWmzD%F<@W4`KjEG`vA)N*||KAhUy z?|3h)kBAa1PF{;gogqqp_7KH<(I{Gf9!J|Y&m3P`OupmTyA@+Fc6%)lIvEE;mXB?R zUbn*k31NXA9oX58jXQ%2F^JIZlQET&9wA<9>`AyMwamNZV)d0>kbU%%u(`P4_%hN! zKB(V#X+vj01Y&Wo%^+S;__MbMZdB+FsE+nOz>HUp0!3jSg z_x{jdHl79h{(P(Jk?v>hg3jmC&HfxWf+ySO)JocPujB zZ9{hTY!q|`KUlm&f##haQ-a($&akc%5wzS~#!3w4j7hh8OOI9$Certzzc*Y_5q$|> zLu_zeLPvy6tn*ma-q&YI^Q=Si#Z@^i43^X73?9X4Dk+F7cM8P(MS&=` ztF64I;vJ-fAIC)Qh}^T`v0hR}xaF~pF3&BlD1Gq=)p_jGQ8&DgXv#UvGLy&2?y?tX zF8pMR{#m`)62Ih8{hIo$v_x#X?tUHT?3U7*W&S7|ZWZN8=YkVFp9}`R+o=18+i)Y2 zsGo1IqJyCttEd{}kwxE#QXkG6_Ma6G=}}W#3r>OulCj&Vur2&_h$|&RaOxWa#h!=v zISNhMcll%nL%@53VpcR zn*_Itjb!Esbww!0rzV}Mc`t>;zKdNPw|i}QCBLX| z^?0&^sQE%$(QLd1M)CF5(%#CH24}QfHr!JU!flq2=|~{>WsG0dPI9sD+h`1; z%$E$|-TL%^HB3Hgg=6OlI`)(A18x@P!Zw3Go2efv;zP2H<&|R6);j%6^!pu7c;8ts z9J_DNT*zsj3{=ZUq0KB6@n(Yot|o^;i`39Y@E|{hzB6|O`*#siLYf*)W^1c_;}nJY zK_coxv6*e&^>L^~;rl3=h1SoGgzPosQ``dTf-`A7s5dHZ=ek>o#NGa3fgT?>@_e>h zpr}@m0(j2Mp1@D_rwUdmRk}=W;pA;RPjZ?&q=6m8=T08znb8n~FneeN7AIvz7UF3J zp<$3gVfivxWe^(93G_l67zH8FH@o)X;CLxQ36YvbBUEo#4Ejs)QKKNSKE&0j3uvaR z-%dy{8o?3w-DRx4pDL&2lOgoc50ZIG`Cqy*S#LJ8&Ih@kzMBEZZKgEDUjKy1kvO7= zjJ41$6HHql>Heoi1F|k-)YlNQkjy>XVlX>#vTtb^{la6l=;)GUUx+(ae)!cp#Yy?_%orpch|8>S+w2HM>F^yl-_D*BXK%phsqv zld4&PRinJxe(F)PONkDCo%Zq+!V&*99>fE)&ZX6rUkNVY$HNt&-HVEWS9Em{Imm?d z4z`cqE+K?>RhcE!VgnGoH3a!5ZZu zP{V5%^^VbHK!0&uR1f|Zvf_pk(6+6!7iyDbA8?1zQnP6DxC@i=KuBSTE;MaZQki2c zU4Q|~267XCa(G) zTA;b4cL95+_Zic}2YWN~&1oEGJf!)pFfNa!vO z@`7^hmnIuK>bk9HL|qWEUKx!Y>xTvHzWNQS2xiRgWhHUpq?nGT*Cqji&B!w^T_Yd$ z;O|h_WX4BAyzf*hNI`2;oE3BqCWO&6*vTU zDl*bw!8r64<38}}tX&az_cQyBMVzhM`soy-Abycu06q|EPHtV5K8%)yL~~#K?AG%f z@dmE_HhPLfpe^g-dX*Z_Z|k?AX*;#H$igX37G4vFI`c`gL{0T3!@Ys)1+qN0?qv+g zB`Xk7Ww6!g6tFIZnsQ;yA)2AE*DH7jQL4z~&{V(5{n2lV`J2XticH@{Ey9WFhhr~M z`wmKPi9#(;$INXp^EkN2*jz++ey~K#$Q5n@Q|Xa!5?b}y(FGQ|&4T>zlMXMO)=+@# zTT$Xz{&*dH#!Xo4r(k#7xpo7aaC<1np27ttyi^siIceL9?j>6vE&L;R`45Mn?Z6$* z*rP`@BsecI`%6>4+32Now4-jtTySDUL{->4y1SBZ^?ieF{?4>dTxl7LipCd{ho_9> zZko;AlNvJPVF*{b_ror(^z=@P-|FS+ao86VfoLFi&ME%N`3DZ z6Jp1x%b36`o^NX}8;TA+Vt7q?nMN-&!sU0T*T-Y~VHIY7UZnfl6u!M~TL0}5w`0%< z*&}7&0cymx!kY1P;@Q+lS546LohW8X8-IxzZIU3YxmY)2n8CeVlb?SxV4e!HP!8ss4& zTH!DUWT7_a2YV2^Oen=EoGe#;?f&<63WvQur%-r`c2d3`nunLrQYd=dM&hJIpzp#d z?8CxlQ%P0q&u6R8_f6E^#B{ndJLkq4tUYM#f1f`q7+6{!^f= z=$>VQ@D+6%2D8Z?k%^dWKQr+v_ZFSNcC!a*Og0K_-_#xxYE^uDnA(k&jr6LqZ$f`@ zU#$H?ZjuJ)OyqUDgRgAqJTG~z`>Mx}dJv3wKMJao&SZ8AujAnpm9}@58{Z?_5p}{V zU)VlowbJUkmCJU}W(D`%P!@l~@;a+v4ttSinCzQqnE^)P2sA<)94+mpZNl0FEC1y` zuB1)cjR@ytVRGUSI3j?NQeCN}?TtFRq?|QZi?^Y|u!=oYPjYoaPCSrJ-77KNd^1Hy z$If`=qUJ68G$P~3v#%Ht4FdRlXG4_wve<@vl>7Fg3f|=k9K*Kn{<&M1(~u?dK`bt{ z7b}6@c(tQO`+tnJrDj?gPpy$lJ_A?7HkMAM#Lmt>@R!|x??AwFzV@pAtl1T<=DmL^ zbnZfw8dA{tAJuxkBge&E{NqFHe{mf9UDt`d^{jkDnW&F;Etn1t^v4M=u2o0tBUic= zehjiW{k&D3jwu`7~dOf2R?6q$h~A*R_iSC!&x&6@vwoQ>?9lb zWL(i-Q!WC8A+WLNf4zpODnYdQCil#SoatozMEcaP@twfdm)8graBN#@K`{N@Xx`XIF*% z$J+~E)9*L!6(zw+`WjRFP{9ul!L10t^juNmTDEH#%T>j`_z{$r1FaG)ba@;4OMVS6 zKcm8X+rBIMW{X&E?dIC#FPxx-=(aQ$VJ4gZ^AtcDw2wH7=)23vcP8bR6}a!YvEN6G z|K7v$+7+{RCt7zb>SqXp6GedSkRZIb?vz~AqNOrdK6dGz#^^JaqLkBY_8f7cG0d(m zzvCTBvCI*mBi?yu=~%(Y`c~6+T)DZ85!@Evf1nsD>ek1MYw?A|qa|!>Z4w6SQkub~ zWz9vmB{hpU^t7C+`;Iy5^`?)T=~aF>o00m;S#uLySwUfq0J10>q^#)DrOeteM6KpJ zJNxqbM1_Q`=m+bc6UEoQlkTX9XBrGNJVWGt%xXiWg=WKJN)LV3>{;i1ohN#I-TRtk zo#fX;-)~Nt+JNVqps&q^RuNcx)AuTF26CC)@qFSwrz@ega2xqbX`2PzbU0OCl%@2L_;GDmV=$D1MN^ct*6Q%)x>b@&qP_xvTKXp9+grHQis&<-arEJ5IdG>{ zg)7rzP__U);rK?|r#0%3BzVkMMpAa}l)@d;1aZFTub5bkct-+XQea+DpG1x5X5Hvy z=KU+dcY`Xmw&>z-MRczgtrp8MOM7!BJ98K6;H!ga*ZG4;kccPmB=kUj%IXR)8D8`rZ2u~SJD9x^e?@rIXD!Aw{o(uk& z{cC}jD$4IW>ON?N$VcfaH@ff=H=wZ{9AyeTWQr@F_YH^^z>%Pa0+{RW7anTzhJxJT z-efpL&Eu$ksT@>h>~5Ge5Mb^;>v) z+>;)!|15aqcdS-b_q!<)rPu$2!Wt_~AfKoGDmx{8I~QXx*UP`!LS*9n=OaXNIU1gN z28m9kHlcPR;(PgQW=4dLSidP=*ga?FF#b(`oG+7Kmdc*n=*+IMw7%s+bvN(yeD(Bo z|79M9UfJ# zs(0#p7keK-y&f=dD{?hjI5}EY$(!5eDcN269C!+&0CDpX4UwC_l z)=@i}qv^qDsNC_MD)<_lki86!J+pj{TQ|R4Q2o7eT)bdXA-&P{=);YRpiXiHjkV;S z^(|Jm?qT-bXDi^liI9T%FN-~c?Kr~(s7VsCc_+uW<*ZQCM`JWx>^6fGSc+6C=y{ny z+qcHjcwMq4pqc5ABBYCB5>21)h+fZL9kMQ~d}~*O<;i;tZBN`nkQ| zLLhK}^UXyLdydUz)bBD#cvQn)Y;{z+0%bJ*XTbc_3|IqGSIzR`PLu;eS4W6+JS{>x?ttN z_j)ByA+f$z8%iAs{rvNbexPtdA@M6IXP%J1`l~!Jx9Dpf+sbu2s61p?er=BHqQUo` z=Ma02rChD6%;~C~CsT*QXH>mdh9twkfeqX7yKY!GrgY=Wy}T(gsbT9!X^>`36Uc?P zd`TxkyZEcM1NsWvWM)5Z8}3&P;r)l{mI8?RWSFc;)Wq28gkeVoD!$$7ZKz3fv#Yg( zNm(1pVY~)>3)HQdKgi$R6~FP;gV)wREEb^iKVl}khXkGTL~}G}B%Z-4Bp@#^p0}iuS0T-3Cn&H0d-y?%vTeddh>YSqHc#Vd6HZ-d#m@HCK<;WTbb~M);2TUVI@}n3U(Op z1t~0&#e%Z)F+5);Vzs5n`=L($YY4jVH|tBewr}6H?_Z}K{^In-Va0Xh2y=8NU1 zp@6sL>6_8s#@5}Nwl}5?axYj%JP0-5JeT%iHZ% z@3*LK1F7d<%VacXVBC>?Bg)s1n}6!hT$TYVEDAZhdkW_J6Xc_OtIA9WM`xLpsL5f2 zgG}1q@b7<`;Hdeam1~rmnMbitEg%+88q$tJTisij?-hVQSj`wTra5wA-WxlJDy)IS zWUqVUxU{k3{%%Go->+BixiIJVW}4={If9RcPom#9oGo2+v0I^tNQ?ahH(DMd23v!8 z*afV%AH0IU46QfVL>;?k;@Sazfl-0@i3a1hmD8lah7f2&iNu7n{#v13f7@Zamb(ex z9-wiDcI7BoD`cG$dlq(+_6h*I+;nvN`;8HZOLTN}P+C03ww1EuMK`fcjc1a K#e6NRu>S}62bX&Q literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..79509169a8fd4bb29b5a88738552fea0d7cd424d GIT binary patch literal 65109 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 WbQ7Akd1*@k0000YBR% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3a6b350841efd5bee51e5dc5c682a3ed559e8d2b GIT binary patch literal 54433 zcmb4rXIPV2*Y!B48AU+Hf=G#qii&~?NGAdcV*@LZmN0_?iqetLl2N26XzWODA}S>+ zAW|bDpi)DX-lUfRp@f7a-@XH=^UU+U@AduA>mnp~!kvBAS$nOu&&?AC`a;V$EdS+~ zUxW_-aq!eHzbtx%{QGSwJo3q4&(2?dS^3N1gW6{Zi-tJ2uW$LhPU1Yv4ZmVpTGgA4 zH=bVj(|FzE?zJ?GMpJtaF0y0Q*+(6b#w`lim8*NM&Ppf*ej19rQ#z9A&6|?v@_0>X z-Z*KdjrT-#awm6&H;WE+>#0F;GcfRj7n#T6O`}8QW|!6S@NMl;XGw|*2~y05VjNMX zHLse=lWrUp<8_cpQ<#=6Dhti?t}YADied2*#b>Jr$W}cylE`ma7_DN`439H?T)}zZ z8Yy&U^=vS&fXbE*4LOZDkt;nY#uHVNVT@YOvN7C1-k(A|GOvPa+c+xAtl+UpY<(|< z1mP-bL1kGu>phj-h2hbu#XJs=W8dV}QI8#Vy=1mvfGjsF1}`Ns_)}4l-y3gJ$7?UnwU29$T#Is_WlTL4sD~$cH$6M{oE@+NTvbcb9(o;52ywz3ZIeeRyK{ zV&yQx<`DOqe#&2j5BV?c`}}&pSQ3k|ZC~4FioE))r6wyix?&>SYbw0z8%A%SQbHeb zM*Xl8*s@VypTSu>jD`<=qJC!7eC8$FjszF>N}x{pU+p=GPp_CNSS>|(hEA2IUb_%b zxxY_KjlQ(fJKSB#Kb4$EDz3okr5u`WAN#z8b2j?$*nMiq;PtG>!3s=O(hHO(~TA?Mn+)$msd-p+Ub*{#@9NlwJ#umc`0~ z<|X+PtRjyM)tWYRX#+;BQ0r8$$wYXK`hf@4nOF9&%OWdK^c<#Qa?#WhOp1?DtU3A$ zDI?9^qU)wxx4LuBn3-zswNj&%-Y!V-;|nYOQAWA6Lg#MUQJ%1_y7NGLdDslB2(?eM z!n(?^uGD!?^rO@?q;uDPTy4(0HGy4apQb=o(2H_>8$Nm2AX#p zmdBFiybiGv1C$#_4@@Nn$TWuNM{p(m;GVU)E1+RHp+egJ?Bg=K$lJ<@(Ms!r37dr~ z{;*#%c2G3(UaN5DZ7uj-cXr;~Klh$3rEvz|KbbpwsbBGFGS2$luZm)*u6y-<{KBI- zHO`e4Z7NZae^cyqTYE!AAynK9%}Q3#JkVcMw(ePst>buH z8CEna(x-p&$WpkoHq#TU+dHv$&R=AHZd4bO#_48>7>Tn;mdS5U-){C>GZcvy|AbTiDXLT$?_2*vikjx3& z>($XN$xBgYIhM&oy2lHPD|^R!BVxqSRo3XRn83OdQoMReUIKv*Pfk;p zE6R36UkMh?@-J@_qYhSkhtH^U(kmMLyA1`L*EOz_pW*+B?y5cohLn;b?deMQg2tyV zXluKI+rIFS@(_8NJQB|^&`WbcM^ra0YSHh71qn|mzq};edxCRIc>{8vU3(IvJx7_Z zjVJZ|PcUK~Z7<8&2&-k373oaV=B^lF>TDUqR$*LPY>mcw$kXI*W^k72^sOV?TV#B( zU)&V~<<>4<@@KDh?nS)!k)! z(WNb>f54%qM!+@AMGyVwd?|N`^(SwP?6WeEd`j|<2~%}-=q>YtA2nB&DI5%*DSd(d zWXQ5k07~es@=$*^=kB?tQdNYQcp9$8SDBCsVU3 zCsn$0I?4XhGHmte!vO5K#&#?1(++z=(O0G}BT!$$y?5KFvm?HK9@fc8`_0Z(o*@uu z9P|6J$^0T>m~l8I8MK{%ZaStR-@~B>+ z9%16@{S@wNd>K?Th2?KIMfNB)A`q5R2v%jI=I(VGiIlt{AL~f>{Qx^2buG1ky6+gJ z48j``k@M6*Lf0}B6gpINvH0+)nQH1YwW4I3L};?)~Gr=0n1M<3$=UoMR}i;;-g&0O=-XHP5@X{ zRElV_LhAAf&inmNclbM zG1R-Hd=vmWJK%!-4Hr$^xQz6HzU~!MoZ;(eL4V9{me%C*K9BO8&f9*eBEz{*GdBB# zi=2v(yDDaF;H{UP5Wh9=H>l*f>YRKZK`M?2l*32FM_*1UAXO0)fQVzRt|?zIa#;5#N`C z0#SXueE(=A><9(ZB0X=ui zFko9$eS*u#FVm@a+LN32`t4q*z;i@N=0l$z{jg-95>mLNbA}tT`~jd3Gt2y7C8T+> zikvRR4Xdulbn2Jj0FcvctKAF5g>=foeofYUTM|7u_7)==-}@A|C^X22HWDvnw@z^h zs!M;?rU8Net5?)0ggEWKZ-<&P5PUz#h#mfU4|pm(vidFBqU$qrExDC*L8NfBG(3y< zxj4o&x%2i_hXKhIZI7@DDS1PNlnZ;e=lxNjJ%C+Ff1k)@;>rT-nFZPJSg9hdpWW-D zY^nbM>S2p1GE0&t%e&L<(becv-INa$10gL6D!B_6eN8H+hTXFjGE{*Lu}NeP>%l$B zyJ;gi4Mi6b*nnah{>%#g0t{`x`QHPHo@&AS-tCff(X__xGu$kU$&D;3*}7b^G+XpTBefn0&k#A zCvs!r*rNo);>*6Y^MucBrLNlMB_(1KL=W@*!mvGv*KLbut2m3x?LuJvdivo$57-ic zwpD6=WxclHslS@p(A7U3q`(eR?asS{c{z%OvL4d|*xm@Fp#>0hWY$v3dth{JKV@^P zO@Ts{skR0U7{?#5%_HDGM?gF_hDv@E^@bWos&h1G!f2%-pR@1@yGOe+yNQ&qnxe@Z zk=jRw??nu*miPT?8UqvbJ!yclBRL@=$a}`R`}OUM0(`cT4baMh_io!1Y4ZX5lzNL0 z_Y?hC-q`kWuORKMSA~t?VWf1WTtt>z*AzKPgg*8;DV@e`(W!R?ICYyU4(Ib_bgb{M z$9+#5*vjp)*(t17a>*xb$?^Pr<|>TIRJqP{bc;X)R-y7Kp($$Po$w+0-`hH%gfGrL z$%dHLkm{S#VWBZgup*WAsfFhdY>bv?NhO4@wO9O2$$q)Uz}!oAS+@tz^fwIu3Is6l zDqX_M@`|O#`h=kU=gcY}5_SmK&rmr_>rM^cPxvc->hl!`4^)-6}qfn@y(k)+mwq(Ei1!(9(lfdb<>)(K&26sErw z;e&wX+Ye(0-MWE6u=P1FF?lUPF;u5FMh?J*lL&EQ2)mov2`K++Ai{Ob3ZpV9>?bcV zM2M4&1jceb(S8V+KY~={vp3}T>#8%JQ}2>4d9#HsZ&!`H+ut$vboU|m>1gZ9%oVs5 z&k%f-*9$-IBHz)jnAcLN89r0T+|)iK&2HCq&;<@vJt#hS@z``U_pVF$;y`i5ttJLd zmv`A*Do6SWYjkU#$dp3m$YWs6MISm6>cyvuZ4*4#s!!?EJNMMCEC-~LO#@MtT#=Y(a3G1dD~1o(p6+DH-u-x4p*+lBIY-40pzM4-1dX`5xoS&4Rc2i ztclZd=K9?@HhnMr9iR6KcLr)LFxD47P<>FW#CExMDD^M%*b3dok)q1-a5X|bHFPiD z8tyJ*Hsy{?=zmtX+B=4o7Q$}=tPm10hqgd6v{{MkS))PN*|DIY8l;Yq3j>)3_A<|O zL=L6`0TPV5H=@>gTkXFPX`oRP2~v$RixlOhJZo?#`U@Y7^jf6Z)?83uTjq0#cVIBQ zqttmp3rN~+qw}Mvk$U}tV6{p4EfP=xSoc69D$^x`u8Uivt6e36$n`OS`;A}&6%Ze( z(ukL`QHrp&?LS!UxaWn>evhn5c*<3I!`o=xv|B#3S({X74hstc*VycUfW?MKR% z{v}ZiSr#uzz*;1NxI0BE9$|qkv-LHtWHmH=uo|gW%oGjV$XaM0F$^yy@jQpoM?J>& z@I6*cw3T(1T}HMCyvo-xJ#`H}z?-`(bs_Y8QUu=0heqv!Ua{~7nJ2v2uwY}$w01?O zg$L(|T1!cYTdKL=6u&YL*$adMn{@t1=lFlp zZEGMJeR0e1_kc9s{@wy6<7Rh9KTBQ&fPRTRB>{CW(!AuL=npzh`Gd3;Yd;0t#Sd0t z>e|b^;}WtlsY|Z_r8P8uX!0<^XW+$BVZGb^&*PF;A1CWCHL<@CgV2ycbBxjEP(MVZ zm~Z%YB>W(_e1hQQ0J?_F@|~kDFr{NN-uOF3ZnnY_uzc zvo~(gVZ1ZOct@W{xRVixA2m5UMxUor1wwE5ybix+&zUe@?UjlMDp$R|x>(K6Y{y2c z1}g^W>af$3C|nI~+fElWu*unR)Vau)@vWaqUx;5KO$W(G!i~*t%S%zUjIxh?fvrq2 z^+j%rCtD)V;g$Fv4e0YSzxR39_BM48;;2p@vhP?$#6t11$_kBv(uJ*&;RSq;;gxbgvy1$$ibxzw z_SxTH`n>fLY;*~3SL~x~q~^hym3s#FigZ7EveLQH&Rcu6b5EVwITa{S?`WLr^ECG= zZL-4f52`5S`s|`;Ro9xt<^P)vgqR+Vm_w1NFkf{3ZEpnfvRO7cNq7bFYgRi5kN&1Rb%aTgg@BV9t^yaK} zz5*f1zZ@?@#KBi^AzIG%ba?(*d?9=fw(O~=u&gH6HG4=SjcN(^{Nmq~&{A`HpNM?A zoC(P_X7FU+IZyy93j&Qm;~MW0d8Kj4xh3SZ^7+wi_a6kvCQL8y2|r2ywxXg`z3p64 zx)sU8G789vG6CuG%=ukj14_1)qF5j0tcVBX@S@z-&vBkAh}dDq@s4VM4p5Cwh(l`ug1 zYZ7L1OO;l7@O2i`cKCWK-Dy*IX`sh!dbdM`{b~}|to6RA+QC0gv!mybhPaFj1Mpx{ zWdFWK9md&WkmMTkG?98!u+!b9?bYxDtY3|D70rb}kQZ=$>!gPUQmnquSa;%NdAP^M zROBKQyRHD}&X6kTa}eonCfWzeJDoa8jJZyy?1bjZ&P{{QCOl+J-+_F1cie*dE!Qr4zDOcyL*mtG!z{ATVM#NF7QJ6hST7sBfZJyVuQi&e(OeLY zuvA$v^!fsMfPOH1Ew3{Akwj3pgx6p~MQA%H+O(9ieC4|8^;gLAygX;vaHYP%l-q#^ z4tu1gtNaYtrR_jjHq7-l1^)Wb*}$?OrnU0fO)jyx!eps=aXumEakeM&n#Y_nGtruK z4KG5?g|7Bd+Mp1dBe2WxE5u8X3(x}5bZi?6zbT>3wu1L-`9=U5+D;`51R~MfN`54 zeO7+O+K$WRnM#KG`FPegHVL+rmFBhzCoY99QhRtf{0v^np$G1)D*Hqm_fGuZfxjjD zJg(j^E*Oev&%mIIk51n&*;F$-kp&tkG=K3cOCF)x ztqk5U>)Y(5DBNB4GE(49!n7&&jqNc`S6JjjPLSFsg4mdzhVy_xNQ!jcb3_ZF-ii^y zzYbkxo`bc^EhJkWHx;k~vxS{#JwfwBr~nYK@9pMyI88lOEN)KN$UggV_0JsTzwzgV z6~Gswp%+T{-^38DmOgf0rhedaYLQo3VJ!cyYOg z@9O=|0~XUwSf{0-R68r&?EO>b#j5P;vb;@UqATq$`<$j*Fs-B}vsKl(F_QIC8mw%M z*$#_(%tV1Y0pw-of7WaY10?GDnHC}Ykc;3uxk-`pAK0`6T%(k9mS z0_?+6`%H9lR{0tJ;k;<yy%> zJ)bb+Kuf$+Kn@=14ppF^rXXYs7BN6DYB=43=KdJ83f10zj_~o1A*RlMb`0r$#t7DJ z%a?gJ4Mtdu=ZU^^X_4n`Jk!G8Hk_-h@p9nXt$^F2Nyk4l_+Q`OWZWm^#T@>M(TLx> zAy`ycQ55&(eL>r;s_EQ3JZVN?h;^QGDXR5ALwelcf)>DD`kFmv=MnMw^HrpBhc@M@ z0O;P$-yBPZQl2X_5F*m1x}C~1du7L4LD@4PKSYf#XaaKR#l)>My=McJeETGO-Yra%5ae!(Nd$2tK~DE-iOq&HO=A2` z4IW8d5G(qdAx!{G8dyTXT+~B#-V$FHq0xP_7jYJUGG?FE~w?M+)#n#_!auZ;kZ>oI!uAzL3nt$r`I;v?OuzE&UIrm~7 z=a-TPP6lFZ>+pv6hF*hN*+gnxk_n~jHmG(&VwX4PokJX1YG6n9xVq}+XZq75% zOHrDTA$EvF8E_ZdcMhQ0wNGNA+-qdT5K$UCtE-^VF{hS@fT8$`^Gnz4!>4orkDBb| zJ2`!a4KMWIs7{-*f_+Yo9%(MnpYDq5Egr`$i^ZddiK9o3F^WFfatQ)iXfO}$eMaIK zcE3PbW1UslmADF!6f2J?Vj78VG3-jG*0JKi%6)SZClU+*G}_v(t2)ga=2C?JmP?3J<^hPf1{$ndt9rM;Gpv28lzt4G%+Ohb~U=4kf= z&ohGo$^#UYT-4^q177$1G13o|K8klkRSyJ~1EW3!jJj4Njz;XK;7=FrVw{Cu`-RV= zAim3YL{E_>0(Fic_VCXE3?v!?&J3)Ng)GQ|qW3@O9rF$gShcqZv5>R*r!nt#@U?$D z@a22IX-3|c@J!P}n?2Gx(XVfC|6HT%5lxjTW?$li+y^5a3UR$gtw(paV2@aO2uG9@ zT>~Pm;nd_{seKwqvYk&Dq5`L}cg!t{XZ5nii11PQ`u-{*9TD`r>-$5mzA6tHjv^U7 zMqHWEx~z2~@-LO1rxB_q3Ey^^gsq5r(rP<@MbAudCAIc~fByDOWjcXya^TsJ#2G`S zXcP5;t1}0y!M9v^^ZBPjuc)ybGbNPdz50372XRADX8%6Jf%CAH1sm|px5$c#l=lc} zIBD69`n+9@>8+t3lfU8p==6iPbnEpg?s3#FSkjyX4MC;6FIe8V>8#_m3hr#md#<`h z@6%SpKIJv`FN@@FDB^|IgWT)POv%}RM4pnsJSRYhn5e%dQR5G0X za9FK`p!%)LQbSWkUrH>QZ zmp5i4vMN0svL`C!GRa%Y!&#mObMJgR$XT1TvmUtBm?dF;Ib?MrF; z`Rg}5zJ!&q=76k5e~UQN+kE7 zXDEvkN;AHUZ6OwQ9c?!iW$hukMsSV0bzdFE_jLOyJNwNh=1f`cUmQ4lw!Ck*o?^pj zsm8#zuV#MA8n0HbG6d~yP8PMORUhmu&Fr7KMh-rS1HKTgi?)ve0qqg>&uQpyPL$delVEPBexnf_4S>=@|D=#jdWQ z1UgdWNIi2(-6Ck3ee^?)k9G}{*q{$JH8&`f57~a4*>GR97aQJsKv6wFo`u*YE|O!1 zAjEO4#U4NA9URP+jGR#r9NE`1Q&|BlwoWTCu0mrzMEBj|nZ7f9euGuUUDH8g#hM17``KQDsRL&wjQq{O-oI~`Qc*Tdo zBKJ=&j}ptpXI_@F5r(?Z4IADCjznDV@M3Qp+D_qTb=qMX_~(r~AwtG}VRq|{bLAxX ze-TgorILHvBQ`3kq8_N(#3cxVeg<*68iH7?tIB%JG=Y{{ zpDKArnZ;+h&^ok)OY$o3*kTAP*3ERVnV;*YI0;pM=>#j`cNYk_W_uTjpXN(;?xS}y zFQM~hIL|q?3|U@ZveiLl4Q%;k6lNIho;bZ{0*eEaZwNj4WOy`+`-^TizTE5cKaT;DEbJ#~z+B#%NM}?Ll$SYLylr(S^{)%#)F7}>%h_Ai z>>B~fD@orJ67n{OuKg$q)RzLz^VlJ%qB+Q%7u_H~@jfa$?vE z4Q-nmZT^-0{8wP39MeNfKy-=!rdy1>bH?yyLW0;gQr0()t~G!Cwn+Q}7>Sg22og2A z8e69&$XUzrlLDh5Csj@;HwEi59QItQxpwMdEt2(Ivn|Q*tmJ@6D78i)`I&P|%?Rt} zwjPU`_54^IbX`8RPo9yVjA#$iz&(im>@XfT`E%@t&|D3q=Le9EjmFItB<)S>q~+Gw zi%8JO*M5DS~kYwCD6?6 z9}>Bk({RJ5Mtyx`zB*~}V2XklKr;i$?Z)LX%0Z0#FA@iylfI?ii@nJ2o5q1QhGv|m z_tBF_bfJjP2kRY(%w5m|K8ViJWbGxq?wNYfADznzR-oQrE21yF{D}FN5$(ydtD$zG zN$tm#=ZJso(D%N0RXQ|sB+hw1NyrU zGj|q?uOJQ3Y1AvZjG8jnrA`qw(DV82Y@R(}LjA>6f3}v}t(A(!8|KtEPh9tS9rU)+ zt9GyR0C(qr#aNI`)qJ}K#3HB^y9y7w!hFaX@=EpYUj6@ye9q|5`uT#2B+)L)4A833z=utlA>)Ujvi4H%)Polx-nWc|y$ubw2?<7P1i!_BR50GiUgNGMfiVx@s#}Xy1Nu9-zjr1cTIV4JG z$CcR+hH*WKG1!#kcm|TF*k>3E1$?ZldtYn7&l(+``R5C&ShaZ>o)lZ>Jh{#wi&?!s z{AcSBJ1X7w-s#UGp54(F;hOO#wY z`l=%x)=poF^F7@W>Z?DypT==%6@u*NYzmq@?WHX1aRXQ0K0s%$%X$T9>z?m%-+}qU z)!|gqmFKNH4}Hc!=887qVi(3&GsIUN{{qRRE3YyubHVQx%_5i6S?;^7%`&_NN~%Yd zB-&o#I0yIGeN!*@zAyzbx*L}UqKq%ScHAZdAq>*(?bCO|L3qF;Uiicel^#}0uLQX5635r^B@BwVEPsi?bxyEfK}f3fEI zWYK~?H>ZM#|CB{?TaV;0at867>5=R~(7tU9jq-XI(jrE_emv-x?#!k~5&Yk_ZZZ?JL&_vD8Qptly;g*BR3@kpbAG#hjhTBPTu+i$XM4j)`n^zd3V@!972< zJg=P1oZ#w=rshO(ma@JimQ^GZMH$|AJY*aR;Dtm*JYcW?ni*`<#lBT-nvdnuOhKL= zErseWW{Lk6Man5wG9u&=CmQBL%h!N>6oskrCs@qpvOCh!Qk!8>aReOM~CqKWU|=Jk$TNw_i(h-FWjG2rhTG~sEj7_E21^myj&Ddk!9hCEZ=|CXT2x4e<_>U< zOoP6iV%YJTR1^|1S}SWhv{k0cI^0>zTGAd7({<96?aPeUB6D>TpR>|=+7$am15)8T>9w&W# za0F@BD3TS~1)ihM99It}%kb8aPokYGCZ{9v-M~;5^OU)2E~Mxi+>+j5Y&(xD|6HAO zNj6l`W&f_*TL1oq_*LGi&*W&rX+QjoLLI?+wp$9~hWqf&-Yyl>JWHiEGs*-vQ8bo4 zbDh+#f7f{=aaz?&!X4w_=__q-X6diW9{be%S3uh^;orSRZ`6$mANR&q1M-uTmSJme-PJE@@9kSH6ZUs*UNq`{5#Y!NFJ! z?%~^zdxD$JR95bn-(@2QQ9AJw2-1w7kDyx*MLAk!JddGx=_J3c4jFt3tbnqrTk8I5 z$Wu_ha-3lc%Cw9!*4+>mApaNwIrPn9W2N8a12H!-hDP2HAAg+>iWdIFo;PyDYohjZ z+OEflRGi~jNEoK9R2P0G;@(Im`lDi{p)BsDf z)?Ap3wgprA=qkhsnR)$i)hfQDXPviPamT+gc46i!7y*r?$v?DV7$6BlzJnCQIs`Vn zb-e%Nq+0DJj+VOq4y}j4Oq<-Hu?_cE_hOqp?74YnCC^2l%tTCIEPwHOcr{l&gX!8j z=%&(pTWvb?R!4~HWFlp6tJy;4`P>*WUaWOKpqVmTs!##)0m`byToUdsU!tgZ)HX&= zstuZ(QTaYeh~V(I#Aic3CUOzTK%B*ncD;!%kv-vZhY?1MxhGp6tN0KPH`*C)bS?2j z|Lq5n`PL&5${!LRMSchw?2CWkEbPrYbxw)K3D3NPQ&V;7n>1M2QUG^c#@Yrv55Aa& zgw_4uT&=Nq7i>^4jeb z2^X|Ux!uxG-#N)bzop(8NhUX*p3p@@W;c=xsOLE$t9VZGc5=<~25S-}-SI)#ZHwfv; zPf!iZd<1{D{DQoY0W^Bli z(^>L@m*r}`@6yneDh%}rW&KCk6R|J&DbTrTKesGAS13vAZHYklG~W*KR~4BYo^aaLQUa1B?v8i!YEA$0?3;Y;p(UvgDitP7E=z)U_ckZ>$w<7jP*`z^ zQVV&*pABwnt8#DnBzDe30t>*_w?u*jOxM7d_2YIOgJrj^HITPc76P zSQHVM`T)0YD-7g6arwg~^H}|_p_6$Dcy^iCuFb8$jdNMOc%aM_-u-%oRBJ*{#SpnL z>N2fIt)t@8H|xs0YqY3aNzUFukL6Di>DR7m=ULa7o^{oZS2pli3U$ZGr|zNRY-A;m zSL8q6iwd=H)^>*IS>}~@{Hz06B5ei)Ugtd~IDTcuIut>8^tbdND7quNA!t#H-TQJl zs1c_{O9Sbm;Ya2TnJc|_a+oi0I)|SwE4_CtbUwIY(06 z$1pp-kd`@bNH7m|IEM7R$PUprZz|~>1=QsavhtJi|Kt}kT_IEUlVj(!VrDrKHp(U6 zpM}h&KkWuuSUzZ3Pg&K-b68L zj5qJmRH76T8)nIJ+%zGH!FlgimEeu&5igj5n@oZMjYW2m*w=#a8yvlzNa==j1sqHI zxglk1=0$KaZn0{m-(1-u^Nb$=3jD($35is;6^??VR%Br1MMIQb>cck&b&(8m{N0_m z@=kF&H1dp}Df1obE8l~dkP1SL9YX)qiY3BW$l63PTA{ki-yQ4`U3<)oO^LKI5-#fBaD24$de1kDQ>ZIx=|Itfu2N04(SN~2$Lk1x8CCDP zxhZv?F-n}h8`b)J0sr^WMr(dW1*1pL`;h5NhH(|?f%R+<8U8`A%f}v|_J)(bP z@4wf!b&TiesilqGFdQ^LEZCUa%$JSfmB}+cC3xxCJ6L01VCo3yMd5j7~ERO|QFIVc{08AmvR&DE9oiJuf)Z--kBdm$RzgizZz&rQ*mR`~^ot_K>kA zVtK`Mx%CDO#ylc43(l7#Iq6Z5&CgrEjj`+|7|7LQoX)gi)cU6Acf6P7626ukxIEmr zqwdTm~cp%P@y| zBtkKoZdZg%etb)YV=zELmTZAya3l^?O(!|-pe^YRmYBGlXPuVKyeWPlu)|`_!>osH zB9{z={`?N8)D55R`LL?guQDokNgNx9zWo*%83jtR_egy4;3ZE@1jV+=n1(}|*Sh>mpPt5f3`&uQvdm#mn;)>7U_Z~X;BC@| z@f3l1f!l^eTAmS?)7BHaJM_4A~UG zP=+!n=HLbO;Xs2`KZ$VH+>r%mL(Rz9PdFLMOET?qqt4`+9a7Ue$$^dLh+@0V)gek3 zCQ_G6PA{`wV&d~%^Nnk%xIL)|Oc*(81=QdT^4;wnhSwHS@XICnGa`=7{&glMeSg7q zUafh(mGyvnjgw_>(v~aO_2B*Ka^;lv?7O$Q)q^$RTq^5%VPG$Irr$MZy;{Tl2S_Xg zTs6<`mhc#APY$bKO0}2F>h|R6_a->yd3Z~EB4hlZiQVG8gg(px38Go|aNJa>b}kSp z9!cTUYP3!u>9NtWX8L8BZVJa^3U?SA)k?bg%#A|U-Wzh@gP3Rqb;KV#EnMSKaj-n_r1N+xHB`)~^ z@Y;-D*A)(npNo7<6`pZ%v>407J??h>I2)vp9ZPH;Q5Iu}|6vD2iF+d;uQ=nuxAQ8_ z0}8R=>f8iZM<8T)V_qL*#_l%2geY+#^ZQeXYynsoPLfan9~i0)VFPnfUDU|0T40?a zi3k4h@_(Px0MpLr5r%guYr`IZWFs9pZdNiie5x&fy=p&aIjFPSbI730F6+*a`Xlb+@}J+6jNeX|aKWJB+T+iGq(U2i6>)glVUJ0x!TQi} zP^&7D15`@#Rh%y6-d6xk71-`~8X6IX9K-TM%F-C=E#j_^tA$g8$}WoXXO)`;ATaaa z4TbO1r3O2`P}FeDrFGQj-!haa%(Kdw55i=K6VMPu6OZ-DZ}UUzlq4HNH~O68R`G3% zC307D{J(Aa6Is_lLSZdt8`DJEN?$OX_c9hN;I;hOW`$Vs$PiObCtX?DUP#!jrmRJ^ ztP3KoCljb3BDmK8ixR51i6_`wkp?!-z{MO2tPW_r22A}vQI0S3mzVI0cdvs1BMc)) zfo*!xuvlXg_9V9oPlJ)No@eY*_R6&r%IA~rnD(e8YJK z^d7&>!v2c5WMPF3hApTt?KAjEhdp!J?IF)LHvt4|=SG#0OmL9RzHOx8%4zTu`eFVv zFDMXpFaD6x8<98J$iUY2nch~8@wK{x+KF!Kviue3=Z9of}zJwRF@;iB**ozB2;D3)rYY;&XpXAAe~~n{h_{)U(^kw$ibL) z*FEff8bPPnz$ooECDQVVET^f&0}q_`co>E8N12OERb4Ob|+1*IDC`V7qx&vNf-*h6wgy-M-Fk>Nq2wOb? zW$*QymiZ-Sfn>ti__o#nN_gvXP^-j^O7S+G$36LnPL#$Gm1MXU#hA1Lx6vm_V;zIk z#`EZ;Op#hRZRJGLSvspe#pyYB1O!(ZwKj!D=e995lJOzx^3|jojN{2?%^Ut`3!YkD z(uC4PM1R>1tca%IlKQdn$dT@LH>q|B)d`Oot8JTFNI6XFx@`XoPsRMbs z(^WIAjz)Yzw&F{=BoI)g`j}YYq%r3bRb~k@z55%N%qM&EZ{5Bj7w@U!9F;=J>BUn| zVYySNuLPaxl3vWO8d|DPmEm|C;UbMmH)g-RS!VgWk@ktaLkI0QX1=ZnX9aD?={j`+ z)~$tk$-KV2&WWY54cbU^#5(Y7IzS7?C-AGvmUjjsBX0ZfhrDQztou4MRXd@yeRl1D zO@EN`hwlJM(pef9pj-uTAWJu_uKgRaA-cL1@&XEEo@V|{*o>Ulvv3{+f09imDJQdb zy@!Xj$QqW7ZvYFQ_o}3D%30k4@o+H zqv~N$Ob=L5HgcJHU8T5utUGy*}Fidzq=@32CndEg-ui`mt#*LO>&;!JZvr`tNV8>9Cmgz8JY-597J5 z=ZplLPxWR-sKcK0cZW;%gl3>w%gLqbv)wYBBVJ{bT}9+pfhdZuM?O0^w%1|LK*^|) zN=rrk>?M`noKvj496}|>g}C=wC5JDR{3C0ya2g6QDVSsn0JTcYi{_mHQ<+b2q+WN3 zgPpW5ROk;@$nuBajl58sqUtQ6ANTw16O8IXNbmPaZ5!!^)!NA2cBkQf zLhl!1AMyHwF(g(if2NqkxjJ0iAwTo>(crg9t*%n7m{8p8U9*VdkmeyPIIw38Ws|BS zbo;UgGXlSTgzjh~$9x5BL@4eTclGJV^{c%>lrp4^-3ia{7kZij7$EXJAAYSA>AAie zU2})2@sk12-@_?C+!UR(%({iX%J+LNy|#WeUEPKx+9~n3dsE~yXCJNJ-u1(3X76xc zQ9C*JADG3Y4F-FRx|8=OIJcbXt_Zi$26yY`_gYmMO&rI6K9uSRXFfgVA9(`v4azV3 z`?Zj`*Xg9)XAB?3wg{r?r^atS10}Td)Wa5^5$zaD8G+;FFa_*-F*Rbz@jGyM#QZ5z z+t0nmC?b$t>?9?cwF60tUq!oKNqw~BxZg;`I~Zh{J5P;SSW?pVzR0`#Vi-&Ej$8ZK z@j+$H56&&wZ1S_1<)lti>}~h(&t=GgkNo|;<4&CW5`6caR>PiLR`s9FNGD&-*=^@% z-{VF!C{-O^hg%Wmdn-(bIHQ^>WkMQWXiQA1$o|a+ zRWKg0LGH32JzpKqv*i{{>rxW*>>}hz0^-;h%oX>0wkN^`T-m0EPTf^nz7OFB8V)aU zkYd9;H@W;?Gf2XiK{~vD60bym#NHsqvW;W((f75G$x$Wz z{7Y9UaBgH~9U=fn5CO_r965w%`TlV09RR3|38Mb=US|ruC2FmUd;~@|(hY1^43-pG z}a8~CVZZ&j7>~*!xn4@)_z6zPI zHM>fMCo*Irv3J9nRtB84u}z1ZI{9mUE`=Dis&GSRpi@r`7S0S|!ck`5a|Dt@Ud$`3 z4Qsf|VY_M7bH-y8#xwa z#1|JFn(hJD;|+*{{m44BL{31sREtrng54$9h5@;6z~{+Un&v_n6%oAf!(lQMX4#GaeKWhx~2vJ*eiVog1!G+fC_;9s3|WGpZ?m)ZscSB?Apld?*E zGwniyDh)s#OMjdfdGh!&{{=)2c1oj`3n2SD$vU@?KxyetC!=_IU|Oc2Od0T}Ra zOj+UpY}D&S%BpSY)>dnH=Z=;mkkO7Fqpl|zxGiY$6N%VB>+Y{5jAUkCiAmucEdSl= zftGe487-LaT`>dGSm`fsx8Hr3JP~y`nrr8Lb<(hiyfZEI?3?iWWXY~NbX#`pD%Wky zOVDQ`?$qkXreeV^O>9Sj0UmT@r5Rl1@bP*?CdyTC8AvwdbJTRDvk$KW{b492jZ2wN z&px@<64(E;oN(iLR}lp8f3J+mI?Apsri|YJ`YVn9Gq3{8668Syym-nf$4Y#Cj@(Yc z0*$O)E8vnYmU+dIeLa2JHmGu-jrAICU*p_*?BiH)yk}G^vRLMO#p%Fcx5;N;eHbpAiU5KGfLXy#(F6pnK2S`#q-X#mP~U8;WZCgS;!eDvt|& z&)Z>I#7ZjM1?Ii`B=6rnd3FmZsl1kN?f%`jwYw#J4e;`lKf2s4d;kmtcJH8%=_e zF7ath(aWI?8eReivg5WKM!}(5UWGvgpda791xh;iCtZL@0d0S0AvcsC$W?J>Vz_Ai z-97!WfBHiw$#~rh!0SQD-_* zTxWY_ofZ&7z}r9{J|3dbtW^_oG4IJtB(!jQ)ptK-E?Lhu(aICJ*zoh*6=&U7Cx2JX zu#5$7g5u4rj}Og0I_3j`CODN5@-G|mHE_h@mCG1bk4Bzo*bCa;pA7T5PVo%W+A*e7 z2QnZ0(~0o+t9cMrd_P;xL&baTth)T{|UqaEBK&Go{Vimod z*%15WKcv!MCxp0e^X<$y=(Mi`#bjSC7$!oQjSVby0k{dqb2F8WtGp+5gWp9={w{oj zsxB#rak9sPvvZzbm9Ff>zOF;`S$d=#CBfDeygJQqcFoBa0qrSJ8CxL%9}Fg&vu`vC zj4LUCp09gH?Y=7|;Mngkp|>kOl)rm`@VU`^lxxS$E@Ma~_| zuAj!0!+`4{LsS&D1187qMg=g|^{x0x>{^}E#3*dX3%1487Qv!a{afret_kPgyrk4h zv6GSeyXGGS6;AxJIhEd^XOTYi5yp6GY<_Fre`7>sgho{!?bV`ClfLIRc9W|%TGAH5 zLK@2lx45K6P!D`$P7zL+;fe~QXO>azWGRQak+>bd;mE^+ki6HrcTtWDy5AlO&3pCn zV{^~R9v(iM8+e%snLQJIBm=1M9uPdGVDImAYRs)!-ip-3)zoB1>tma}(t0Tmcs{m! za)#k8ykBnoq(=x9Lk5kSQq{bhu)ZOtUjyeR9E7zSc1hn-gC3+Hbb460*@5be`pM8eHPd>y>{QnR?I8VEz!b78Zy=|Zo`@0V7t#Ia5C_m`3-OP8G zx^DyV)So>oZJ=-h4kl2W>3enK^ycAV`fd4gjqeP}2kvd?vitx?1xzJ;gb*xQ+I1_2 zL{^TFT|e$p>6}G2HSbSWb3wKl^FVW7`6ZHmw{oOt5--^JXaxkI z+wt8_uQGLUMi|pIsvS5x7$qYBuC1Q^_r8kM1{_z4!9>0IZNOE?i}WvHsb}<8mDgKJ z0(B=H^mw(a9P})PHD4 z*U^!j^DZ$RXdbD?p`-^K?V~XT=8IJ`KnqF+;9u1M@#A5EX1@|_Ffr+tkWFJ>BviAoB+ zGr0AfJg`^&e1a6K&?a77bWc6flPl_`v4)06Oal`x(Suh$sU~0$iIkh`^yDP8m?ny#v9_R-HwuePVP{7 z(pE2CK;;YiDug;&u(l(P#*&@pWgwY8&=|Z}GQRBy`SZPQ121O43;O$O>?dOX1p$9o zVIaF3I{xm}PLDNrN&gmto+19%>Ed5H$Q^a8+9a1>2w6e@WqAG;U*OpMW%oqb`z)#P z{n1uL#W@_$-r2P78;nLwPHpUq`zlQNL@Fpj();Q;HcSR^LQV>7rDV2JVS3%W|6!_; zu>EJP`&{g&3P-5^Dl~J)Z?p9|;?|Ua6CW4{+J-!at?04%c=bU-yO5Z`yo*3);9%bG z$w>t@%pN#|-6h!d+pQcl(7NF{5ALJD=6_Uzne|`CEwxCT=Hv z^Ri`Nnf+bukSuyu)OOK(<$IvLA*j4KQ~`W{?his9McNY-hXNHJo%Ru|YhLB(lIT0_`njO{81UGS zJl0aCrJRgjwOUO8%A`C$%Iz&__uX(Gw2Y5Kn+!Viw09Z&LKhb`i1wtDOHbp{S&x!f z$qs*@+p1?ciINKf4|3Kxjc(|X<6^G(0@&Q0B+a$-qyUtpONF=K{3B7-lbm@q&JU6C z$ba)4r?@oyV>QhI7lp3`monx&7-Cn`Jh%sV+tqvh8z(BLUZ^EmO~Gnra}^ya-UZW| zJ?;37S!knlo7=^=6dV!VcH!i-Rr2iM5k|`=#c^CRVu8B>B4l=73w=CyoU5!3c3=VP zEz&?YFVtbpHu4+j0k7}hYB>6M-$0FZbPwPm{}$vwq6@HF`r8K_u?9RtkTJ2tq$dRl zi<>=1C+RY?r6@P`Q%6dH4h^@%Bp1*?;2@j@y!6gB;3(^aE25J^EQ9s>vFV_Lh&*}# zj0y7>k(r&Yjv+A{K#VEz!Zx})9cU$t@`gjhxE>Lhe(rBx z06r*bBb36wN6+6Ye#gn{q=&zk_rb;<4Q^){MCk3p^WDNwGo13?Go~?+@6Pn=#?Z-2 z*jDM9`~nG$lRa0l5!msl*6DiP(V2nZV{?J(w@Qpd*-lWdnk)L-eKjnRe}0Any8V7wC<7{%TvKRzTE+xNU79Z$gjT zc#B)7C_Dv$58Ksj=n+$pa``4Oi=PiY5W`0=e&-Y~tJh&0GIj z9?Fto54hI=X-(ihJ0EX#w)JnEvw39f;Q8AJ@V}ddMAYZy=Uw1~5PK~H*lXFSX(HU} zS8nwz_4?q+$*EZ3E{ma4laB!h7Wdo*u#!}b1j~FttrK5>+LOV)K6v>;2sdlC6K@fT zRO?1Dy{@AVneG1v+eKQ1kjkHIu;a0^rD%Fz&<Nyu<}IJcfA{MDz35_T)sbM*MAe!hqkhZ1@=<#!{X2V8e+SZ z$6*qv=rM$pW%sf0orAWtlb6rKKZ7W$@C}~3Yyr_J1y9gGT74R{-rsjLwo39-_19Ei zfeE_cNL5Q}Frl#18rT9l`OZsTGp;t~-Hra@xx8>3@cjAe(e=e>{X@aU;FJPK*J|(i zv*_ztKC!JR%Eijxv2wz#8jZ#;)v&RI(FH>N2;L0xob0|11txn zL8SAhF;s!U|%;=e=z#il)*Jm{VUlu3VSVhlisEy zzLj6B>jCg!2j-=}Gy>BioA*`k8;LK;$Ow@dz4S&e`Fv-A4>q~p`~Vt+G#SnB7~G8{ zbxi|%fFM(kg8>H!8!J?B9lB zh3{-Y*Kl)xqFQf5^4g!&}{eAQcWYQS!JHLcypF%<#S{Zq%>-A-b=xjz$}#5RrFnPoaW zxG;z|H~Mz4#A6!>fYyB5#~YO5i%f?n>_a?DJ~Y;4t@VninQm@=CM!PBlroZqu(G1e zIa1XC^`x~o-K2^VaNrkxpqlzo%n9>~1t=GM*sGcs?KC_DeS@o)p4${h55~{XBQB5| zt`tOJ%9U*eLCgt#@}>K!)&z(+|7;^L>^SOf`>A#glsm)A97%wZdqI5j9F*2shK@Lb zdpY;tNPZ;(BXf61681Oe`LNZWZJ+}W%?KO^D`5#S(bELbiaI5adD~m`*W}*~s*G17 z=ufwe#4G>!!6U{jj^cN#qu+9_lm7ChfqUeE5&Ep~;!rHBOxJl#QujtkNBh=7Kuitd zPB{?Fmcyk3feDM2@OUR8^B{mOUv6Iuz^-vS!&URmTSE2%o<{S(>nbRxu3tah#<`V& z{8bL`*S^Q}`mHZ8%e}Rm($&p?8?$Xc&e> zPKd`Qt5?KGEna5F`med2vy(F3mfYMP^uQJzl_zv}R{l27Q}5`a*o(L`HK<&4;DiDZX@2)lLm& zmCioA88l_dLb2_E-;&n?LCYZyZ6?T!v<88Z&+l&Z)#2#m#|2sW6E_r~Hy$;efEb!P zK3j0<(Cy9hE3@>71LhfT_|NcDZuL!}Pew{d_d#c5d8-=e2ECi&MQ)Y6L_Y!D;Oj)$ z@|T-@c2=LtA&aj=?lB7smdWVe^1sXH|B4UfETK`w7RggSwj^li@Ykv755Fb@v;)>& zr3;YO>vRSEO14I(+Ivrf*h~Rh)JW0o0=Doks!in8=8;XYsGtRJL@EM|WJ_!zY_AXB zj;=$!Zmxig4yq64{Dj`2UC<)}o(EQ= zcc3GzN}hdJN1=pl6hUPjl&XIPw-DBSa{2AY3dB)Ia^q!WllGtrf1FFf00&~>BsD^FeL-ct;V)282#Ey`~e?CaXVapqvCoT#VXZQ>H3W0Cyd0u*L~eksS|{$0;1S z_-TqOA9g!d*(k6wH?n`-4}_4Qx2DPUkW zZ9I^}d+ZuL5J?je0bt$ zt$V=Cu;w>2H}Tb4{#gJ1k$w#Cp4UL#o*hiEhz`Hl@G zmOiu}eiuW1oA44ZkB~sT-I)Yudo6kgfhdl2*+)bQ@F+Dp=rmg{6sgG{f*-aZQXn`<)`0hjNG6l|9cF5Nmj6p>wBJg1 zFaM;t^5S@x)O)A!*0`gNJKafU`|HQ_ZNRfXr!dBXY(+NgQ}5Tcv{$#DTG>X96^1@W zQ?u;%$vkjsno^b^{V!KwXLONfLk79|s&f9DkTl))Qzbs8k0r+heZWkFt`AK#8lrfBg@ zBuXystJ}K7Gy}8p_=JjllYHw_)U%(gf*0QQ7sI2rS#`I&3Llen{+&fke8OABv)h2K;m#3NF^CkUrr+N?MO4uf$Jp$ZnYM_ER#?{H1rx$@nIc%r@ZQkJ(CZ}1 zQZXP5*nXJA`(wS@zHq?JrFeu)YVw7n9KN*4tw^G-;$J#*9hM3gy;+CoBow>=!lEQ$ zs?R~8Ne;x6A4P_9@N55HH6osOuCA9rZ`Sxx(3WEroAA5gKZ~~U>Zq^7+<1jPFZ%6; z{PM$>7IgDvv(C1*jty{r0sfoslGt$iNy-ncR|Z_rDM7nCWqmD($MN zF+Bf#L*7T}zO!CTr3>M4oN?g_vTc#+nsiP|w*$Vrm41=8 z6%;GHS(Qpu;8+w#vR2mnf)dCc5mkh(#V3IJ_L^52TbW(EX}HK$8rk8ZgifiaP9WE!0CE8=y8Vj~g)+0dWeuCd9lob{pSq&) zgU-e=7NPJWQ6|~sC39zAL>?qZIcU-gD94#f!=a=?e%*5~jGyW%NH2r@H;U#7f*7TQ zrXEmmcoiA2d8Z1RL=AoFKE&$|`X#r7xzgmxCs(2f&Z?m+lPq0!syFV*yP9?qVloh$ zPa8A`2?dFvA6@O4Pz1yF6IjuHDn;3EKOXDnDviOQ^K&ah=A|&P2Mly0aU8DQ&vaca zB|jw;_PiCDhRskj(`l90NxCNWXfB<9ZcUlAcMvLkBhpj4{NIlz%A$>Ca+h=^63<-f5#OZw zhPotqwx?uWvcgpPo{0o&OA)P4S>QfJi@TNh`hE-Mev6KB63TO&jGhvmXS$HJF2)8k z{Uw=Rb!l$V*d)@oMXfw!zWfDu^7|cC-*AzejUMHE%Q6evlT34iWXoKZ;Ucaf2~geY zEIia((>=a^a^KkdVT&0f4JRl~o$@~VsqUDL!b1YbrJxy_OFp+Wq-ph?NplN=4~rEX zc4#~0&Gb`rFd*=Po1iyrbc?SA%ng2BBMm>{^En|TDt*fpiTCyFD7}Er zo$jgt|EORKqK#dl`VJ|!{S-kHO^3^@RMTJDrpySZD~}0SpapWdO3M1mLyZ=(Z1Rhj z+rA{hdRRfmdAMiwi;gNdf;`*CUcXC@v&KxCh!}8J)0Lwd%D&J?A3Vj<5+kPa@t-1< zUfY!7O%p}q$NneQmfnLLLm0ek49JZ?t;_OQ~933yUr40C!f1dq3;om z2p7q$=A)TIUQOq%jIk*0LK`V76+ZWBGQOL?jQZ>)GI+tMY=zs(8#UB(-{61il}+go z!{+Um;9r$`JN$)_p9@)}nsRyftXhg2zB3(kP#}BCtbNLjhc&%e4bg84LJWAx{x^Y z@QGf`IMVm_<|ulWUm!03j7inmVafvfP+B6M-Ex*QR5_qV{q$=8@{K~7SI4r(@8JW5 zl^P+~8@PbzS_^*uG{IJz-*Rh6xdz)4D7Z{ttV^?*fdi}IQ-Q)MJ;|wni;7*7o>-a< zWE1E}h$~qVXUKv`qWydKFR3&W65C~yJ9m=l4E#*JIO*w69`GybKr7tt0E;GDD-_dX z{_dSIUcW=Dm#8sp!SeibVS{*i{((_O?g7K|yHS(6T%Jy2g>2s9iRC9YD)j5r=Yt{; zzLuL(_A;x-bk*Hedni>JVBQ0funUSwff{GFGl_R z>$kK{N$BAK5BrD+wderwHyQyGypUO;cwJ%2i`o93EQkCep&(V}*tOjs8`~engv$@k z9&Z#mLfD3dYs0XsV#FW=pqr1)+sv`fBFQ2HLku+4bUlx)TfZhxsUt6>^=x+yWk`K( z(o3jRwEBUnsz~2SZXgpqnton@oz z>Q8ASG0)!|W1(%ex5?&1_sJHXX=mkb%oz9iGdP5BkyAJPxby()dO?_jZr6gR#v6D*TQ`*-2re0ub@S7WWFsx(D!*vbcr6>vU|dGa2@6JOVqS{BKbwIy&R z9L~lZT;SLlqxj^kmdkj7+3xg2=VC(DVGv?-Q2I)~#V}z3?SeE-!JC-+3Ek09ms$`& z6XV`7B{8qYIkdo?NnEeXYT1|}(GgK1{pwWF8JL;WJMpD0WJ@fR70 zv%lP1q|+j$3GUO-&0raRHi8tvC`^LcRt;gOm5xd4iT#L!|LYKh%;8Rt^OP>#SB=?9 zzt_P4qAk#kBK?|?0*{)ulDs+1c|fm(;!PH!%q2xqaxY)W-ZI$Z0_#r#B2 zgh`SvtEO2DYee-@C@YydeXhD7Eo&B)nS#DDpc7ninCgD}2Lx$`2^xReMfAIcB)H0L zlRnoi`g3*Y5^@I9GLE2T%fG2e?gU^P*H=lM86f)z4+L6NvH(AZaOR7BqQdEC8w~MK zqGAcpl>+$7@osPob=0Kav5X^J+k>Us5Vevwh?yW*qdsWTXJf_=nl`@6HPi{g_`Czp z`Bc)ih(QpiJ6He6Um#WR%O}Q-S9EMmQ}O;?`)cU5QoM7Z==5rnI~)0h`u&P>1uvik zRKn|Q<= z9?jn`Z&MHb`J+|h^0nAT1v)RZrZasLWr`{_Nh(MpkXj_%q*lL6k-zM@Rde5tRctvZ zr;52RB!|VsFVRKrnf%vRtZ!fR`Cr?l4bGu3$LjC5b$n!%{EpX)A6;uB+doOxA{V#w zwxa;$J1%Q3Q=YP}dTT^~WXB_X*SE^0=_uXdOqpFHz$8gi9f*R^WJmP*2j0@M`dd#M z=^bo;01&(}jsvisDejnJ9wdRdv%)s%Q(aeiyKvg#Ozo!mZ-kB27Q=4V=bn1e zWLdU;B@-?p+=-oJ%GKyXYti^_GaW2(E$FIM`qKFVgwh*LTwx0%>J+G7qbQ%ln->7^ z_6gJ5^#&NRh`Xi9hr!WluPK6C!jFiqdQ?~_h`HjvTEAKv%b_F~E{(jD{t$+LE0qIA z%=or4b;B0#91Nrnr;9fRocuv!gzI>Wf7hSgafdYgAkIfb4?05&dFhmmK{Q1VU2;PC z@4?A41vYH?&nhDzFk?h|R8pVp>;LrjCq!TUPF_o5j2*#cC-L{%Dj?hAC%@M@L8LC_ z6*fhzm5r3Ds;!GhR0Y0NsHgs17~3s z%gZ~@4}P^y)o+7A4H8Pmsj+Nr4)HWYexM|eGBcCSUq+e4;ycD_p>!9i{7jQFJ z3Y+47`#TSkVrMnjnFjC-|B^7TlJvMYGC9K%Ll0Tb1u}x>c$6uZpDiRjs@gz5Mmkuk zNZw(`8U&KYI*W^Hxz+I?_X0tf%LfmNm18XWknZA~D#3ve758~}OQE)nFRhfCGJoof zA*eGT>8tqI>C{B9%7ua!c%Jd%^tVLKT?t{DqC8yLzb+yz%Uw!s63i}b4UjLW=~seL0i|=dFq4&1)wJ`;SY5)ir*jkKVD3 zcGd6LywOW830N>K)Dx7QDm^=_Et9a}I!qEzmgLc#+!?zYv?%2lT@Xva$n3Wj^b2uR z`{JLhGXgfbo5Uvnz$Y%ah6OYudD^~FGE1bcByD>H-`VVH6TJ9O5+H3KhI*P9x-hU@m5Ra{2$ysb%rN1p7= zjo*2&6lY!K931U*4>*bj3>+doGx7>AtW1(N{w%f_<`u4@4SQ$T`c>b`i<^eic$+Dr zZ&SrCH%>#jQ2kwJ<>7VVLx5kXLu~05CZ`0}Lsmy?yn|s+>m@##-t@1W$D7Z~0eGej zB$mh124uwYI+jy1tfFWMM30F6NgAF0vP}RbpMR3h&Fb?zIm9{!8-2WKOXI>Q&%h{k{2W#3xFL5jT7N} zr7}Rste2d@B#}H-E3g<=Xs&yB{u?nfYrNS$^RcyZ{X8f4M9)B6%7M{)G{7oI5nAmT zeG2fDRf@A3U5yFHqBv*AP4qvf}7+abBk;4pqXM*eYo2ImF+e!Lc7zM zT#)#BpmnO+iiTJ-1<&L}>BhAp9XBpM_kKPfEXD)jVh$dd!4kZ2n?agqQafsa9*{DC z!m~`R35?3W3{ox|emU4?>TEOo${sb=w8CF5_JozsVZ20BzVgaU+f;_Hb*wmO&kYZq% z{G0C3AahFThgw&nI@Y)AbueNV9ZNtE*ddh~m@Dzv@mtB=B|^?sDe*-y0IDThp#Fdb zB6kI{hjR18Wq@JHR#hcVzneZI};Ta+i&V_+6O z&b~;^Zum3rYfOIt6Xg@$$fgLNs`S)rRZlQ$wF&u$1jsY2Ptw!!Nm#RwZ#4(^^KDt8 za&!oP{9nQjVBPvqm+6>S>*M2ym)>)>sj}y{zQcriN2T6OlJ{tx<`#LKKR{4)9S@HB zTh~hrhCb)jNrs#FVXl}ivP_Nek&syrZ}Md&M0pA{54&AHmrf+*gvoR$l5yvo4&CCt zV9;t)vR!*ve{a!Wx-p+RX}<;=#2hwN$O4a+FPefSa)ONg;=5!<9kbvC26dq(B!5(G zlr=oL;21qE`DNL5xp{N(>0#ugvnt?PMgy}{n3gn+f(r_pE=<=-(nYf5bl$n6X3Pwb ztT@xL$KZk2hpY;0st5N!7Q;>gLZzh;Nk2`8DC%g{iOra zt}JQwz=~j+BHAB&X2nr;=kJu*RLKFZvIcLED&b^$x3jv2C3$@; z?2gRyV+Q?zCj~*jsV08{zJML&G7!$vxsR7C0-Wc;^^VXWvY5tC07qgiMMn+2)D_ZLXg_=}!mc9W6*^7(oul8T2uGra`yj4|{rwDI2C8Y5g|S@lV{$iS24x(C+?dfpU*9$DcWmW}x~pmYC)CnR%UsC({P0hsEU91L|U zd@Whd6t>uk9-3i13LXTKA*rj(j!jEzIqL{mXU%)li_mv^_kLUzyj-DWx zru6vEMBk`D92+nMzYa#o%yz=>x~vZvedx^faqs$OT^**+5b`?Y8b79bB?O*mT}7(T z6hh-Sce~lwdCE7EwWo6Zyc?m~!!DuotijF`gzb+@L4nA%_jmUYXO)gt0_n0)oJlwS zdKTNblGdc)TqSGIW9QbdtnA6sQ4yd%Nc%InDoBt5_CtPC=>FJ;q$aYtV%o4bn>e_t z&ZQ0-pnthGx&%Xw`4#^KcEG!F3JZ2=0g$0Iir*_LZT(#Fuogppk*fDh-3u3rMg9`J z8)Tqb93)lzS6DE{jYbelx#KI~Lo92Y>4h(faOGxlko<|(mhf}%qy7KcJb1$-7X)U0 z`{5T?OXZ*mPs%5y0yKeXolqc(WV+i8KXRWco4f%~bAV8R=7tn$+8xliFzVR3)gmE8 zo(NVfQ-RX%TwORuxS0$_f7a0D=DZCm=+!O1Z>|P>BI40X%-~!CCTMfM53(7CgC_9& zju^b>-2da2j6sZeS^3la7fK&Fg?hj}^17oozBLpZtVw^mQe-iV^xeqN zX$LIApBpodHD->f2#X$fEbNyR$g9!VsL3qe8s}|^rsfff%H=_b@>R04?SUJ?Z9MHY zLkM+zn#ojrSq2b04cJ!#WiSo3CF8t|H|WTm{aTiJQ^J|x93|+}FAjP5_xZiBJ77_< zayGydsL8_-Bj7$DY5c(r`P=)?g9>s&g2Dm@WPTAKfa{m|mTY26(mq!t2P{l1@PYC2 z&?SSrub$Sn_iD^XFM;4-b2aGw$>k-Qo;wv|A*)z@Rz3;KCVd;w@ht3<|2IO&!chv_ zVE}p2g0vC#&bRgJW|s+^h7%QIp}`($qnRM3A2WVQX4i29ukmM|#W1xq@%4@9q$cSty&O}hHU7xoZ=rC6>(WFhS!;A?aGZ;UE6Q|pb0Rp=;9qX^B#R_E4%gpYkAXX^j>2t_D#Rr znLMLH)Kc_NOyt;RlK9b+wtVI|Tbd+oq6{!?|r zBk20#ds4aDMG|iGp?r@m(DYY%!%1oim;>JaMptdbJzl(#3g$Q@d|q|Q=d^ROb_QZx z1>#fpps7uRs!Y9Jj$x5AZOOlN1%$`~+i7J~5)dZ*CyBgrH*#F@o0-Sajvz>4L6C4p z)!*S#&j)?N;a z*lx+WStP$)kb*^xg`L2Y3KRjLoFR75Xd4cdmVOsWrkh4kA6R2Zx#dusGaK|8|EgZp zIz^5>VbaBt=glS%IS-~g{Smbe9=iU3y2$=eO*s90gnhnRyZeIMWxB4I^E}w=M0KK6&YA> zZpf$s<{CEsOhj(`ne;bL)U0mjDHXa@+d}7(8$a_rM#kefm!i@*md3zlo?wdkPRi0)~Zh zMW&K4qTMb0^7;v1yf-ueXkMEz>4+efm?j7!1*!osa`{8RyIZmy;!Cdj^#%NrYo9%M zV*nKpfH1WFAPoPmNdFEps&mjVM)}~*AMRAXh)U5A2eZ&L%y(jA64*fvG4YE}E^=?9 zlp~S{=#!Evp{cZ#K=zcN_g6;cXwQZzhP3D0^VtU}RT>Jr_fJG33PtaUIbwo9pEVuG znbZ?K#t?f0!+aYW`A%h*G^;f)ugmUU_lQPPsvc2qo_qczY0qnP12Nl;=vc+DQpjT8 z_aIg5HajaX?7MdGBQTprIG*q^nK|@b7jVcZc1-R$;3a?5AjW;&9<3y|SO732dxHK> zraVZVRO%)4g__d6WA-PnNi(JU0Q|0TbALM^K_6;Oo~wC8DYhe;h{~z?w&86bg5T+R zQS!au8JvD#x!VmO4p2Lid$zO)XD0oB>eS7OzJ%}uk(y#xLSy+{8+?xKd*y_O$eEGT~U^d{5`w9|ay114M78Oy43xbRUi4=F- z*tf+F@D!7v!pN&jf(Zne472>nmD{<0Zc!&nd5+m%_KlB;ok*A zq$#$OLq(&TvLw)j84PG3Sb{(=iJ`Qn=8f57Xm6=}DK?zRH+d>k%lSF@p1)}IOrMm< z8y1R5(Q)Ou9g-p*{jCl3aJbJM_H6G9dfrcqY(T#)3F~>+0eIp00rY)kH3r_+3m52i zNQXkZ9KgAdO;loefXL#4>nl$a07nldI^R7pyeShK+|Rj|W7kls36P0b_UfYqf`yX3 zr(Pz;gho|0CS?k^(<$-Gz=V45mq?LHY)n?an&B3n-g!BG9?ulSu-}p>w{;dvRM}Qr zlsDpPP+t&z)qdg|%A|e#f=h@N#&n9im~64T`+jZnUcfTZ9>S?h6>?1=dG7UJqur>X zh7rYKo+iT0soESsn?ixgBtRwmNB7sz^PH9UXdsMS2iBi_Gn0d64v&Y*Wn7i^JydS}$}L>R9QMu)hg(^=j84ET%WP5cp;vdbVd_LdgT}p;6Cr_?emo{b zjUU$;s+(JVL>p#X*4Py3+NxE&tJJueosYhdz^2pPGa9ydsl=@iRVqL>bPcZDNghLA z5;W zvJlq@Xxc)3DZdi4RC4fO2efm0LL}2`!^-*}{)&(WbRws$lFrM#$;9 z&Y3keO6nO(gJHV4IqId+IS;n>-^7hQJe&*$GfM|`K zkZm}EbVO`~w1I+0oA9*J_vfy8d9e%~aw=8UZRJE{88F5d8pv{K9;op7EwB#*kR=sy z3a0+p-CVJ-iOABUm5+@P!aetgAaI&gN+6Ny9t_$vz26xGbfjk-*M5^2*X9b(hP_*4 z$}N~`lc2kI8j;;+sQH-yq~er9|K00bvc11d&MwX$l3ZztK5vbvX)VFqJz<=FQu&jkEv3aHlQkC84^1#0YB1lyFiM2sTZp~@OyF*4* zy=%+ro;p>Lp|sQ6XM;0)2-_9$|Ie)U0)s4BPqY?CQN zO;2snl@XX0m)>6=_U3Kp-=k=|Uk2Nyy~zJ1q)aaVt%)S8ux=-pR;r~ckK`7gj#)U# zwC6IRASK|M3Y5un&PLbPl6>@2vey z+L(n03qKlm;+rv&eDk^=l$K*VIfPw_(zyX3(wL$^vPL>fN%_cBH0)dp?F?jc``w2A z+hh>Vb&)h)OSc&AjM9dLix9i}jM;&2=4;&^) z!jmzwyWGhK2ap(wTbWa*6?K6DP4z@32^lUDBj(#YpTn88lWhi+b}e@2B;jGU0QO0W zEY_uGo#+lA6azU;)Q!FCNOs;O2JEGCx5xy1+HO;Nw2x_@DJnBXxD~rMcmkdmmRQao z8=r?*W<8xyi!Qm~y8(uZw3HB7Fgfg56i1IBx-uw&WI?kVWM8>Q5D#C#^vv;52zm5# zqS3mXZF3F|NAG5wM@WJ$+!dG5D5-fwSHa8FM>ObgHruO7)IQsj&(3dI?SKWEE`u?u zS;{T}J@M|?HPJCiZhJIgX0+Nzr|0G9#M&MzVajcmB?}S%h6%j($4?<)$o4yF$~>KU z7nb)*&p=>b;W{8r&qaevOFid>*YnUF5Y<%4bmDGu5{|}Z;Xk5IwYb!)0BOnDQVI); zMFI^6dx4L#(tVtmrxYTnPLhu~WA8;(GeO!PkFy4b{`LFi&N3HKxw3IK3&G>7ELtqlBW0 zu^D+C9LmA74|?(6Wz^PtszYcwxFX#t3LSeVKhm{e$Wlt50T$o*c>k{dOhJI73Wx?R z;EBO*xSY2gAL+~&37RW}$~y`Cp6I)+UkUeFZ+Ab{EqYd@q+b~J(QJQNCX%ER04ScXj&VJ7DF8e12TGfexl)vm_iH zI?6wP5CbdfSpBeE?5{KPGXXjEfdHoE=tvtf0JB$3wvLW``VR@s43a{z^#AkW2e+bLdW(fMNr`9 zV%}DK^=A~pG+rw?sCtt8Q4Bx|I2LJK*6$zcZ$Sjo&+l{4Y?*a=Km7#?cq)2%kvL#P zJFPv}z{zI+N7UqFO_ryCDVTyCDvKq$s%k%RV)Ev^Le8rp+?>%FLYaIUi2gapo7Sq4 z5<9y6W6r$*56)oyh$z{drB2>dJ|Dznryab(PKvxd=Uf2&;lGGV74W_A-*>@<^Ohf< z_!7#EfyT&+{B(g$dw2$@uUL76=7uCK&%~2K{U9MV-k__Y`_yPwUx>Q+wEXFS%st0u+-A4V7~zIidiTB(#ft}N^=Tb z&($N%{>O~>ib1k_FE-p-vVhS4#mlwH9n;CJJWI|M3F)Va-DJKZqw>`n5XhJFy_aO_ z9VhUYxd4Vyi?X9k=YZ&C>bWue#@0$`*#VTZv``>{T*|4QVcA#JH>2JoR%=Lg&-%Hj z3yBl@)?{RY=@k=BGF(!-^fq`e1T+ih@5HW+ z(+g4o7OEt2O1LBP@zl=g>k~6K*9TAXzF+PK;yl#^>tksT|HFpNSLA=W0GM3~S8)%B zZI;wH?i8sWLo;ZA$=s;N^7OeuXd{M1MX@y)@NuP-l0WW?ud+BrUfCRWE0TDiDVaJ@lf^AyWWt|>H+P+^2Be&Z>jjl*=u-!Z_Hz;-z+0N!qCNOso7xu(CQTtNAX%!6*LaK!gLk@ zN>u*VNdN`w!5K=4vgh_Me|BDH8PyQqkD|C5y$~a{Lp?Vdc>Wte@q5G{gNtX(!HJyT zS^K@MqO>;dt%A5`Y$olVk>%iR?I|3x=|YPJ0@b+unBR4L1|)Vx3+T%&W=R>d1p5K6 z6)nn_@D#uzL0nri<2k1$LSlM%%bJJdfEKWqvB$r}0P%&>Uv>#@UFH!&$5ADD({1Tc z;%+n$^FSLYCJ$KcT>jB=V*?XQVsDNYWqeWczh&aH=%n9&<-0bezl*q1g8QbrTIpg+ zX*SR`9}qVh;LpF=V0g|m6#*BO%NLN&$W3}Ib7T1#OR_S(+isVx^8&hIG=996W!Ryu zT|1cT80luzLlveDbT21kYK5(34D@0d1e`Cy$zHsxeHR+4u}vOKKF#s&p%&wUiPM3> zb75f4&0xia0NR1LFDiwdBw9O%Zl3B!@~|87?h8BZ0!$P}Q@XO3?T$^8?fsv!Nb2y% zt;qKtESkb8t@aGaD%P_;maDgz#4Q?r;#m3$6$s>!VnrX{vGx~X)b#QH*VcE(Q~kex z8(D{ptYmA5V}{5Mm7*vqnMaXPws10!-I6|KRAv+Xtrz=9 zhiCx{f3Q_;oI3-urfK00b=@;;0O8d?@3`@5NbG&D`O2$1-To~_MFUUD`Wjw` zV8cGE3+@fTCU>u(?5CgT$Z3&J7miv2!IOlEC>iUyqo7Qj<6IvhHP_81ZZ#i{7aXH< zX;eMu8`V~H?rx}mTk478r(*xNdi}`$ZN5D;342`6_XOM0s(z?*?YJSDPK%g&@^y;s z5toM0vQoL*gC7Tls4&k@f_T`=0;#holRwt|=)9dMjA*8#WlR_5j@t+UWeQUHaQIVT z#BKgwCzWqGK{kxyp(U99fMMgI^odouFO?1j083IeRIxW_`Q3Ui$WFhc!g~uFgKms5 zH~jRO-oFRg;dR{eH&ZSWy$< z8sT}Dm+iCkG)LJiwvu)O*lbP!Qbn5_=+e{jz&fFwQM$LM-fQK{wlO^pjvrnS-BO31ghaS2P z_~Dp>kizMuZNzxoWxO}Wl*Xklu5}}=>&v^}fEJ$%87aiyBEIm?_}966*N%=ACr zuiBY(E6X4ihWgTI z(Ewk+(cI7A3KePfU`q1AO{)^1`NAT2c@obz(UQ7$_G1D`Jb@Qf zY!|Fy33vuq|lgyDHQpW|AY#b}2{#o3m79nvPeDDKqb9-3L!~f>zy=QbbNdj@2Xf zVEW#8?<2Dn)aJ!cLT+o@;*B)$6?67l-p@7bYXI)J=c*2ZtOJ*69o38Ltfy%rb>U_% z#asKbG(p*q$6L?FC?dP5qj6|~YfTfM%XN2(j`es-?2UwKa~$-UZ(^=);?Hf;qd%U? z+Hp2pHf$I8?yP4x*zKc`YjWF4={Z0*Xr;~V*XTCjJLFwiJD=1ayXC17d&h-k=CrRI zj0TylUe}y8?#Yo8Ju3M&lHGAH=`Q($*-sh^^4jqJF+u$a5!yb2U)_w<=vUnF9-@BV zDt*LrP9LMbV|>l}Nb;Z1sC2y?nY4xWl;qMfH)5#mR>(qZJkR?d(}wGFm74c8TO1a; z`P@c~aF0PUVny}0CCi*0lM~AJ*7Pt=0{jUm&;^E?wz`ZZU7JS=AH&yA`*emrn|4$G zX%<3S(<-}f@jqZ}=sH)lmG3)(5O3`H=X{V)034Z|Cx`R=z5?;`syJ~GN#@!zBjK|V z0kmTal`CI_oP64rcI1QNsczc|&2x+UM$pU-QX?`uyio_fqJxb})FQ|bxn^>;iv$`3 zb`)CLJnZ)v;AkjN^{(!23tTOEnbl@qM12SJ)}x^%TIfD-W*VDeJ2OoSl^gIGoA1AU zX8+dWPY1?U^RFFteBQ!$UmLQ4hgd<0gaioLcEtA@=iiPL*VQb3wV>!A5PI;+dj6miqk{CPU3+jHjV4wvohQA#=D~jX zcSX&$S6Bqk0Idtxyi8%|+VJv)$(VkmUdhi>w|4IwdTQkseEo5XJt%Rx&g>hy z{u&KJ7I2tBN6`eFgGf>`x!VL)C4&mP?(~N$Rod_Tw=~C_Tj&^7EB)U=}aYQCFGU%z4=20joUvvx%vjv=y z*3<<=V3Pf2?-Oe9yin8beK;gWE>sMyXJZwcz?G<@L8uMxQrfWrsEB#CC#iD~b!jc{ zd#W;T11B1=_&>hT#I;(sf#*#79r zA?lr>fUksqw(sn;x5SJPHy*s?i-X?emp8`AwplRv<3f*96Q@YU)cW#KjZtkyX6Y%1 z`kwMPbUNbk?w*!N*_b)!qb@of`cB#bPoYSY5h9w-+)89n+#^?}ZigRv^obo`sGey+ z)EOsraYpjTTQZH@cgd~#`R2rGo?qBLQD3(Cbd9Otml?^;=&w*hqSw6-GV0D!JUcTA z{lCJ|0O|CsUikhVfxNX1UC_H$XD#GQCZnX6hi#-J_%qI`uc%#LD16q=smTeJ%}84aPk>!i|LI=G!s5ZjDvHy zns%sEAh_tzmx9^gN)>4bR43MEWeB-Eu`3G4h|5%mq zrI}jRT2?h^Wog+sc0MY@pM-i#7lZR@-lbMMD%Lb&;w&jue@&UJW~k-tIMP&Y(_FcN znWT(aWlT~$PU`v>3m~a*H{@*Gy3K9gTRr{hfYEGQi$PoA`AhxCJu$~pH)m}?-5#6L zdoVg@49Y+Q!!HccIX1BT5B+zl^pqd}dF-@N6ge&V|LjT{)lI7%D(WL03#tO=IGfEh2q4iPw;6HbsUiWxNc2q#{|7*Sn@qcw5vYD)Yd$d!KND2V&|o9e zcwJ0MJaz5LLLWeHIXBI7LMRNmAMH&t^4pha#uVLtgcsFrT=_m2dQ79Q_J*1N%h4L! zi>5$X1~_iN&|zuuX3iI?TAzBh*6lZ0NZXK1Gx-E~7;fQd>gb$W*czjw*O7z*^Ak?` z(Q^D1Qi>`?>nKaFtnkEk;bT!GBX#K5?tF?y^R27XGJTN`!C~%b_UEVrQbKv z1umOhYy0CkK>TzPfuA$+muLd6)4lmzj8^E=nu+5lGE06J7#)I{x$5$Lz(}vYIZQmF zqmb}5EB$rV%wRC#qtRjzT5)|DyY8C0YhepcB zrc4yNdh!2xeeIlj{6_e?+}*rmQ$6ASej;Jm6*q|;)R5+G zrH);Gp&?xUBPgOGiSg65nV{(4X2Qp0Syv*J*dC*g>(-=Wk(iBl)tM9IxQ zq`m}l8d??^uh$_Dsq=YM{Q zmO2Bd#GFRM^5e9BHV8N)QxvFFp{Glu8bA{@mb*KoZg(siU~4^T7?A5uVq?8i`}3-& z-#S%26DF~4W-qsoEXyNC?lzjFWolZVMk+-E<&!VGx{{BDC;DqTYSlR;{e!y11Wts} zXB^rSXV7_e{W}BfzvFHc?Yq5@Tl6^ZdJjR*Y{&YZomv(C&XX0RWqUQy)I!00Wg!#v z_kaPmY2oOEEzUx9mXw+~9-VU;=|$IDo7_axGrj=4%TLFCc3So^nWf9r45VpfoK3wc zW5EDHSOZx{ml2{&nBGggj^O`Vdj08fr-jIpG;X0onCFxRaWZuhm<&E4H94rGc%5(m zs|dum8mRa{)&Fmh!nh!BacZ*Y(z6z}D3iGyQ{CT;tRBlq{b*XUxs1K^cu5!J2wbjb zV!q837-0{APA&9wJM?6|0J$F579DZ@aknbnASRF9DB9RHdje5x4`=I}1 z4y!fYrXsIjm{^7Ga=x{Yd!6bh0EL?T5x)90#IiR(hvCwzGmFA%tP^YHG~ta|uZhQkc0}32#0?-)1mpYCA6mG_jfR zoLp)}r`e51l3F#QTG&G{>cAFB)LB0u20b{Ke0WA;va5GbQl|Ix>-EE1ozlr-^6ui{ zy&9%C(5lyXPSW;;FYlKTQionqrf$lAuLz-yTj*FV0Ov_;THIDaO~8MswsojTV5S8$ z=|SUQbe^oK;uqp4W9eIt!Td@=k>O-0yyT7*%zXfwJCx^m)I}Twe?*o((f?9HOFowJZodR1U8<60Z0rq zsgp$=&9a`SY0e?};90my)?v4r?_P_Wd(asvCmyU39K7#zqD(xLsfHemL0@ksG*k#X z07etZ+K>*3%B7NtS>~+YEp{lY@&tK?nOIdAy1=#8kpkTHU64?(N{FOZYznKLYRms4 zKA|8Z?s^{SGCp?7SV-1}XZ7i#iPQ%f zy-h$6_DpTN8vcXDzS#W+7S`d3Sk0d`V6IZ!pOFq8M+p33qi^?QREnlksLEl2^cXLU z^Kq7BQUwOkaXrw_eq&~SxfN~m;hz|1GVnPUCehg`y|e) zl4mZv#jPP~?{v0!&R`(l=bPZ46ZQpG^xgVh%C+~0P?O5+%>zFXv)pYqsW+ddJ)7UE zA{BPq_YJXqXXb4ch2*#*Qu5i^-+) zDEVY7YF4B0EvEhYY_D8OD4t40JAu6oTQxkL$et~CK(h%th@>(^5x}pmX-cLQLJF_OKugOf#G%9OwQsuHcnig zcn?Klu{t4nGI{59e)d*!$y=9ZhI&to;29#f1YmVFD|9-{m*sWjGDo{v~$6f8+#cj*i5LFxu`hPKz; z(!09v+1B$>OC>fWKd*a}g%3TvEt;2=SXLxYKF}m1iz;W%2c8_? z3uduR9+=An4@6PI{UO2}%cY1655WgTgKtml6M;?&e+X>@PMG+?5XD($l{E@2R%pF` zlcoJeEYUeUfL!d|`*MI;#RuF$kX=C=wcEL1WWvaC#NaBX0&l5GnXPIT_g7yD17Aic z+RiUSf}t5)Y#oYisZ@=*dZEQ{K1#3DRARdAT)oc=r(S23->P?^i?QjMTv{_Sb(C2C zuwp!q6U@ZN>ANAXNgtmhFHDw%jOF@Trf;sxuzCamufm$NP>Elv7+w@)$GZpY45-vq zx4)-nb(iUQA{9})@Pp zXIC~Si9L!2Dlu2D!$0pN6r&B3hBtmRa2gUWyr1mum54gBQhKgurmEzUufWoQ{haGL zC=!X0$^ErSGiuqXDd2}49>d$LS`~(iM-|<6(?B3MaG{#wb4~=YB1Jq3yyB4h_=V1s zBXUjOzoU;*YKaenyS}ntqDMHdo4rb>X{z$cN>#^v$=Tg!rEflH8RA#P@FHspXgJ?s zy2Q!>_sT~a*dR~i^#u(i=JpSQ-Rc?j@-KhqK`+)M7n6h5yO z#g6@6-ITi;BT~^*7Tt@8Z1utg`hIMe`G+)Z2>dMD(|v*EqXX3 z)%PnOWWOxe8ph-7IqwoLiKDBPA*O5b#HodO%HQSdbTU2%4n~j2HiO{{=&!bVR}m2v zgX-e&PP3u%&<68x+R|Pq`O>I%sI!Y=jcPpkRzC=j6YhdmN>TK!rQUEy8_SmO@EmG-Mgmv_9r``brB+Z{yb=q6dR5o?4U(19HICz8Qw(0FxF z^_!?p`-2C@OE5W1MM`+eb?j9CKQLb6r=ny5q+2$=L!lv#uM2U-Z)6wu?A4 z)Y*CFxXDX}z1I(OaaTBUSG!oZtqr8~7kP+7Scp$iT0Bxxn3PGFvdx$hS%f&IOtQ## zdUfstYVt`P1Z0No@gvC(Iofecx^ICIBu*gS)1icpt4smS{_wD(NF|Rcpb90+bmIaG z3K*Z#9yy2vUCio+=BjpqN+17f5_O~wKs5%xco5ZH5p}u)dG>{Bp7rkZ3z_&@B~iI_ zRv>_Yh?l^O!&T`+nU_%;85fDDqGm{(nOt*~&%9V#)|k1wrcSvo&t*)dro+CA`8{2J z!+>5GCw&tg+*IH`$uD~2pHCmUlL(;}aV^6J4q5q)H+o*9=J50zWvGM#YO}Jer3iZm zlGQ9-w%@Nr?T@gyW`?iTuh;y-Qq!)JP=kJ549yP3q|cfkw_Z=iT3{)JSz>Ul226=i zz=dItw*(-47laubx;NbI0wkRBY!ywnUd_3sBTrQt2sK?IiW{#wrO81EKR``{fhtD5hmem920^AZ7x zJMZXP}%qy9ZK9|z<7P{S&LhL#WbBQ_{moDNp`{rHES z-onJXwRR?$v2|LZ`d8L=|Mx~&apGZw(9i2ZfAe{yza6N1_xLTh1MK&cXWNKEwj6^M zq;D1dhsYvW4pGa-jdID!OT)oPtE4ve9+~cgn7}3c_8|vut|K=RB2l#!4uGzZ4Nmdd z1v;ikgWYJ^zh`xrI{r{7GM7i)R)m?JSNtVd%eq*iy<)~fu3_e4-MM0vsG<@*jnp>3E)9<9i_0hUW-)X%E^c++>_@H{H^ zO$zotqrkxHz$a=c62BRJd%CCJRZWRU0GFY>WM^jDt+#p3UZ4|zJT;Ny%6I8e>TJyi zOJVi)$nu=xkMMjAydZYMKS;kgl}J>+ z#+uoDX34L;W~zf7MnQ2qY!(BNh(OX;(52aCAnd4flF!+g8Yh)_dGzeWx{8>EfQ1eckL)4L< zwV|{Xs@3qKY^x(Zk;r4ST75$usSno6&gOYy=@`{>iX?1BNlxba7t$n39~tCi<4lvO zp8Bwb>+ZmPETiA`yC>9Y%d6)vtI5BG=^s5!iQl_N_9#5Qx^^)6YNQ}~hVtC3&e7Zn zVA2gD8N`e}yi6Zzj){W}dQ; zlTw@-PO>&&n&Nr^?7wt_T&oP2Slol$UyyIC-0k7%ya&ue&&b`>=zMS&k7zpBW&w4p zgYB0Qm$3rc)dw-|$V^AlFUKk4WJ&1^HvzxmAbgN{0 zwP$BzGl%L%%>;^q|1N9%cA24TkCB0co+}KQn-*>I#$1N4Dyn?yF4l>2EMTb9bS`Al z|6^U1y>M~f{v~Foky76oY*vuCg6|Cu@TOqnHLkakf?_@f3gwa9dx{e@Cve!+855{Z zFzV7pTMkxoA*ssZL~jsV?+EM*EZV;4Brx<&8dXgOyZw51PV2{?Km}9sq&9lxmP+1OlWX_qttOI1KPha&ak#mMPi} zLmKzb-~hdKR=~R9i2JFWt;Ni_-S>&a)!Rf}eu#5kLO+LWi* z|FwT8CtCNaYM6^IDYvtzeBZ?K`7h?kShaXVk_}^3>f%eIg-uqh8l*A_0{h^Hardq4 zjHM>;M=OIg8&G6DL?v8B$YEMjnjWfMRA*49LDG51>Rd``zyCIIkoIRusegz+fkFiR zJ}*3!Sg}g=oPMgW;tJARUBu}j#G=Wy(%5n{$GB~@$%{<_Z}d&?mAzTFhaw#N>X!Uz zMq1a~&TW|53pD4EmuCrCp4pd=<61O$@AL2H{E>-woy)|3vcg28dyd(~yJ)Z0ffKnR zoaZJxGQi@*?f?sc-U|Pa${7j~u;YSv%qNoIxym5Mgzy!J4+QGx(=<{!$jjJ!g@4RscNS|xWS?)TSve9!5TyD=74{&^I z>M+qf6wdiQnl-7%Qi#0NVg?@y#^a&7eB0(3s?5NA`}A!Wmhw zK@ChjAD{_#exh*A%q;t*{Rk_HRBl#Y4`(1Dm~pP>gI?69Wq#(#PHkG(x$4TtNPEY% z{-)u<)P2Ez$PYn1|Z9OHQu^r7^8lJ^2z>>`36H&P+eMfFisq-u)A=uB@-`wg~09+1#d3p zU-`@I#kg0_C5z^(Lj!XLgCrIAcS+42p`4qjzv@BIlthi)yFPWZv-(mg_d(c^b_-3L#reI;yI9*iG8aFO)hr0Wt_G7i4w9 zw{`?scm(a+Ey-fkYFm-C2z|K=4T)y8^64g{TvxN@nj2iV^&<_%)H!dG>*$@6yyiLX zW3jhcZeFK@gh#c5VuHGzAmhlGiEKiDHX&kmGn~zD>Oz> z`1N|Sc;EIy8U7a*2lbdj;S)PJDFl0jsEfZc?8K%ALX9iYIVE(-!?q4Nw!>!EWFHWD zJ0Ivp5BC|8wPLZdUgqycB4j%sqh{YB{2_IA#jQDzUP8s9#X--@)mu@0a*sceO2-SC z{lO95LVw|EQuJA*{So`mjR`11C_8=h@@ikqD%+L!LKbd}Bhl;sjGX!H!Bx}dqO5SW zEhA!*m-w>lf?jl^N=ijDw4zzWr9kVUJ1pc~_UBZZr1>7VR9t13p)AFBgI z*$%Bre$d+?o8uaSU6lO97Sv4JxP6ufI^xR>3gCHs;_rQE+4n6#@tM^LIBlHU@xT2z znx$hVd-MVJ!3c!6 zaF<|L7|z`UaTR@_?y9}qZnFmM+8H!a)C*JPMzk%_IJq@D&Nf`>vyBh(5}1=m@F*1= zw|==qYIi_HHns?hV8gM*Pw%G(P)xqaCM0L$*B0=8F>Q>b$qYJuXBK3R?_iICbQ;a9 z2c5l$f`$z*;xKu{`yI)MnLy{b-}&`xac7u%gkt?t|AXA_-vG^7DI4D!>R`JvtLO7% z%V9XTd4t*BvbhV4+Yy2Kzc-=@BXuQd$mt0IS;Kxz9yr3Dy1BM%@H5ww5K5v$#=H#Q zW{{A=5Ko?ZBvZ(>o2M?~8c?K;xF1!mD{k4I(**5t)m*B6hayL$3ahLW#Pm6QC`&F- zke-BP{xdbQr-2!{|K{d64UwG{KnWgy+X&!I;cu*T0200NNHLf3Pqf6BiB^KbG^1WX z@S__@q`#b!MKw;dYA7{F+n>{^UVEQ29X+pASl4+$@4!On)H^>Y&F-wfu`2gxU_o9U zAabO`Q{!NMQ%;Fo98AGMm`|k2tNxZsF}Y)HCFsa?@=j&WqHg|M0RSvrLVg#mIllkA zOF(Ue_q7*2`{b`aTFLW6H#+OB@b2f_>R{!ybZQmJ0M`8_@J2iu9In5$oG=F|gw zJ7W!SkH#MxV+PwZ@x^40*z|L8gXw>-WNqABj=cBKnUl!Pvpnc+x*>RzW2FX(?`c#$ zpjxrQq{R41Qjl|`AMFtp)~4h7eMGE!ZF$wqp7VL_^kixH9Ui8U_QWD->oBvZa|PVhj>cDAoTAwPWn^_OJFi&=j$S_OPsZu@uFii`qsZx z@aK+{NeT+;va>oTFOzBE#J!IbLc9Yv8D97#9wvLZWLOZrJ2`bg@^GTro@c&ot+?W( z@^085-0OmqzQbCcUq+KfY(P%8Ba824f&r|2E9gHu=J7>mBjYZXTiefRtYt2RYbrkf zn6s-e=eh*QBl5ML4YthNWe3)Jg5j@6d@nBWSb9EmCNQhDrO-H0;ch5r`Q95DuT9BE zi}VP7mPtlQFr51>x4^N$?KVq8Tq{O{nvKQC%UBKuSRw!e@|FA+zteBg*0-Acwlhre zRpQeLpbpWEQhF^<0~pAX17qqxkWGf0ljMNZh*b9=k4 zvS)u&hz9)j2lDw_FW&V#XxQ5sCH^-##UiEeZ9e1(?da1qK9Vv96ggJh<5$o8kDIgz zrIs!BllfQmOm4u#6Az&%Ab$n*`QR$oIVjj!nvN%Y?sSwrd*Z8nGw(Q?ce#PQr5ADAWcaqo=M;F>U!fkph4N^0_jT&t8qgyZy=%hB19Llq=z%+2bqB~>wV$)3 zjI93FDUYrQH0A?U982SZ)@80QQngG~$+*v|>At9vwd+HE6F+kP5r2$0w6o;Y0|nb>{`hlD{gTfqy-oPm z?=Yy#x3_WbkJ??J5p!rs&wGpQsFBKFyEsZOq!c~aS5?}3!};F@oHhn32Jg>Jz3`z; zW`L|s$i!=0#JN39qsm3+0hG5+nx9>{pj)0QX&Ci)J&}tt41x8Em&RSnw#NhHDjcc2 zPSdEHQU>BI{t1kXA_qEhG&M!)p`r?HKk{vcPxTJz?u=vj%isz$sUj)V;4++q?R%e8 zo!xh@`~q?`wuj~Tz2%YrFGW3UbwPq=Wq$9$?!N~Ek3|@cWycfGcHjFn&9Di)mG9A2 w`+wh^1WA&Gf?{XKtz6ZYf`Wn;xy95JVQZh7+8nh{z;`Ilp1PouqlF3nKU)8`@&Et; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..94a06ae5ab6e2e5a5dd8f9787176f629f13d8c8a GIT binary patch literal 101108 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�K=suZpiP@Xei*t8+|CZn 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

*t552>lv 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 F{|}coD>DE9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7f93135c49b765f8051ef9d0a6055ff8e46073d8 GIT binary patch literal 63721 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鯊%1Et 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⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc literal 0 HcmV?d00001 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