@robotical/raftjs 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/devdocs/devbin-backwards-compatibility.md +105 -0
  2. package/devdocs/pseudocode-to-js-transpiler.md +563 -0
  3. package/dist/react-native/PseudocodeTranspiler.d.ts +6 -0
  4. package/dist/react-native/PseudocodeTranspiler.js +115 -0
  5. package/dist/react-native/PseudocodeTranspiler.js.map +1 -0
  6. package/dist/react-native/RaftAttributeHandler.d.ts +1 -1
  7. package/dist/react-native/RaftAttributeHandler.js +108 -32
  8. package/dist/react-native/RaftAttributeHandler.js.map +1 -1
  9. package/dist/react-native/RaftChannelBLE.web.d.ts +4 -0
  10. package/dist/react-native/RaftChannelBLE.web.js +59 -21
  11. package/dist/react-native/RaftChannelBLE.web.js.map +1 -1
  12. package/dist/react-native/RaftChannelSimulated.d.ts +1 -0
  13. package/dist/react-native/RaftChannelSimulated.js +9 -5
  14. package/dist/react-native/RaftChannelSimulated.js.map +1 -1
  15. package/dist/react-native/RaftChannelWebSocket.js +16 -1
  16. package/dist/react-native/RaftChannelWebSocket.js.map +1 -1
  17. package/dist/react-native/RaftConnector.d.ts +11 -1
  18. package/dist/react-native/RaftConnector.js +75 -9
  19. package/dist/react-native/RaftConnector.js.map +1 -1
  20. package/dist/react-native/RaftCustomAttrHandler.d.ts +2 -2
  21. package/dist/react-native/RaftCustomAttrHandler.js +32 -44
  22. package/dist/react-native/RaftCustomAttrHandler.js.map +1 -1
  23. package/dist/react-native/RaftDeviceInfo.d.ts +18 -0
  24. package/dist/react-native/RaftDeviceInfo.js +8 -0
  25. package/dist/react-native/RaftDeviceInfo.js.map +1 -1
  26. package/dist/react-native/RaftDeviceManager.d.ts +30 -3
  27. package/dist/react-native/RaftDeviceManager.js +618 -107
  28. package/dist/react-native/RaftDeviceManager.js.map +1 -1
  29. package/dist/react-native/RaftDeviceMgrIF.d.ts +11 -2
  30. package/dist/react-native/RaftDeviceStates.d.ts +27 -3
  31. package/dist/react-native/RaftDeviceStates.js +31 -6
  32. package/dist/react-native/RaftDeviceStates.js.map +1 -1
  33. package/dist/react-native/RaftFileHandler.d.ts +0 -1
  34. package/dist/react-native/RaftFileHandler.js +61 -23
  35. package/dist/react-native/RaftFileHandler.js.map +1 -1
  36. package/dist/react-native/RaftPublish.d.ts +2 -0
  37. package/dist/react-native/RaftPublish.js +81 -0
  38. package/dist/react-native/RaftPublish.js.map +1 -0
  39. package/dist/react-native/RaftStreamHandler.d.ts +11 -0
  40. package/dist/react-native/RaftStreamHandler.js +66 -0
  41. package/dist/react-native/RaftStreamHandler.js.map +1 -1
  42. package/dist/react-native/RaftStruct.d.ts +2 -2
  43. package/dist/react-native/RaftStruct.js +97 -26
  44. package/dist/react-native/RaftStruct.js.map +1 -1
  45. package/dist/react-native/RaftSystemType.d.ts +1 -0
  46. package/dist/react-native/RaftSystemUtils.d.ts +17 -1
  47. package/dist/react-native/RaftSystemUtils.js +51 -0
  48. package/dist/react-native/RaftSystemUtils.js.map +1 -1
  49. package/dist/react-native/RaftTimezone.d.ts +16 -0
  50. package/dist/react-native/RaftTimezone.js +153 -0
  51. package/dist/react-native/RaftTimezone.js.map +1 -0
  52. package/dist/react-native/RaftTypes.d.ts +27 -1
  53. package/dist/react-native/RaftTypes.js.map +1 -1
  54. package/dist/react-native/RaftUpdateManager.js +1 -1
  55. package/dist/react-native/RaftUpdateManager.js.map +1 -1
  56. package/dist/react-native/main.d.ts +2 -0
  57. package/dist/react-native/main.js +6 -1
  58. package/dist/react-native/main.js.map +1 -1
  59. package/dist/web/PseudocodeTranspiler.d.ts +6 -0
  60. package/dist/web/PseudocodeTranspiler.js +115 -0
  61. package/dist/web/PseudocodeTranspiler.js.map +1 -0
  62. package/dist/web/RaftAttributeHandler.d.ts +1 -1
  63. package/dist/web/RaftAttributeHandler.js +108 -32
  64. package/dist/web/RaftAttributeHandler.js.map +1 -1
  65. package/dist/web/RaftChannelBLE.web.d.ts +4 -0
  66. package/dist/web/RaftChannelBLE.web.js +59 -21
  67. package/dist/web/RaftChannelBLE.web.js.map +1 -1
  68. package/dist/web/RaftChannelSimulated.d.ts +1 -0
  69. package/dist/web/RaftChannelSimulated.js +9 -5
  70. package/dist/web/RaftChannelSimulated.js.map +1 -1
  71. package/dist/web/RaftChannelWebSocket.js +16 -1
  72. package/dist/web/RaftChannelWebSocket.js.map +1 -1
  73. package/dist/web/RaftConnector.d.ts +11 -1
  74. package/dist/web/RaftConnector.js +75 -9
  75. package/dist/web/RaftConnector.js.map +1 -1
  76. package/dist/web/RaftCustomAttrHandler.d.ts +2 -2
  77. package/dist/web/RaftCustomAttrHandler.js +32 -44
  78. package/dist/web/RaftCustomAttrHandler.js.map +1 -1
  79. package/dist/web/RaftDeviceInfo.d.ts +18 -0
  80. package/dist/web/RaftDeviceInfo.js +8 -0
  81. package/dist/web/RaftDeviceInfo.js.map +1 -1
  82. package/dist/web/RaftDeviceManager.d.ts +30 -3
  83. package/dist/web/RaftDeviceManager.js +618 -107
  84. package/dist/web/RaftDeviceManager.js.map +1 -1
  85. package/dist/web/RaftDeviceMgrIF.d.ts +11 -2
  86. package/dist/web/RaftDeviceStates.d.ts +27 -3
  87. package/dist/web/RaftDeviceStates.js +31 -6
  88. package/dist/web/RaftDeviceStates.js.map +1 -1
  89. package/dist/web/RaftFileHandler.d.ts +0 -1
  90. package/dist/web/RaftFileHandler.js +61 -23
  91. package/dist/web/RaftFileHandler.js.map +1 -1
  92. package/dist/web/RaftPublish.d.ts +2 -0
  93. package/dist/web/RaftPublish.js +81 -0
  94. package/dist/web/RaftPublish.js.map +1 -0
  95. package/dist/web/RaftStreamHandler.d.ts +11 -0
  96. package/dist/web/RaftStreamHandler.js +66 -0
  97. package/dist/web/RaftStreamHandler.js.map +1 -1
  98. package/dist/web/RaftStruct.d.ts +2 -2
  99. package/dist/web/RaftStruct.js +97 -26
  100. package/dist/web/RaftStruct.js.map +1 -1
  101. package/dist/web/RaftSystemType.d.ts +1 -0
  102. package/dist/web/RaftSystemUtils.d.ts +17 -1
  103. package/dist/web/RaftSystemUtils.js +51 -0
  104. package/dist/web/RaftSystemUtils.js.map +1 -1
  105. package/dist/web/RaftTimezone.d.ts +16 -0
  106. package/dist/web/RaftTimezone.js +153 -0
  107. package/dist/web/RaftTimezone.js.map +1 -0
  108. package/dist/web/RaftTypes.d.ts +27 -1
  109. package/dist/web/RaftTypes.js.map +1 -1
  110. package/dist/web/RaftUpdateManager.js +1 -1
  111. package/dist/web/RaftUpdateManager.js.map +1 -1
  112. package/dist/web/main.d.ts +2 -0
  113. package/dist/web/main.js +6 -1
  114. package/dist/web/main.js.map +1 -1
  115. package/examples/dashboard/package.json +2 -2
  116. package/examples/dashboard/src/DeviceActionsForm.tsx +158 -8
  117. package/examples/dashboard/src/DeviceLineChart.tsx +16 -3
  118. package/examples/dashboard/src/DevicePanel.tsx +92 -11
  119. package/examples/dashboard/src/DeviceSelectDialog.tsx +224 -0
  120. package/examples/dashboard/src/DeviceStatsPanel.tsx +76 -0
  121. package/examples/dashboard/src/DevicesPanel.tsx +11 -0
  122. package/examples/dashboard/src/LogConfigPanel.tsx +357 -0
  123. package/examples/dashboard/src/LogFilesPanel.tsx +200 -0
  124. package/examples/dashboard/src/LoggingPanel.tsx +264 -0
  125. package/examples/dashboard/src/Main.tsx +12 -2
  126. package/examples/dashboard/src/SettingsScreen.tsx +9 -4
  127. package/examples/dashboard/src/SystemTypeCog/CogStateInfo.ts +10 -3
  128. package/examples/dashboard/src/SystemTypeCog/SystemTypeCog.ts +37 -3
  129. package/examples/dashboard/src/SystemTypeGeneric/StateInfoGeneric.ts +10 -2
  130. package/examples/dashboard/src/SystemTypeGeneric/SystemTypeGeneric.ts +41 -7
  131. package/examples/dashboard/src/SystemTypeMarty/RICStateInfo.ts +34 -3
  132. package/examples/dashboard/src/styles.css +766 -1
  133. package/notes/web-ble-reconnect-retry.md +69 -0
  134. package/package.json +10 -7
  135. package/src/PseudocodeTranspiler.test.ts +372 -0
  136. package/src/PseudocodeTranspiler.ts +127 -0
  137. package/src/RaftAttributeHandler.ts +152 -76
  138. package/src/RaftChannelBLE.web.ts +62 -20
  139. package/src/RaftChannelSimulated.ts +10 -5
  140. package/src/RaftChannelWebSocket.ts +16 -2
  141. package/src/RaftConnector.ts +93 -15
  142. package/src/RaftCustomAttrHandler.ts +35 -45
  143. package/src/RaftDeviceInfo.ts +27 -0
  144. package/src/RaftDeviceManager.test.ts +164 -0
  145. package/src/RaftDeviceManager.ts +705 -127
  146. package/src/RaftDeviceMgrIF.ts +13 -2
  147. package/src/RaftDeviceStates.ts +49 -8
  148. package/src/RaftFileHandler.ts +69 -28
  149. package/src/RaftPublish.ts +92 -0
  150. package/src/RaftStreamHandler.ts +84 -1
  151. package/src/RaftStruct.test.ts +229 -0
  152. package/src/RaftStruct.ts +101 -37
  153. package/src/RaftSystemType.ts +1 -0
  154. package/src/RaftSystemUtils.ts +59 -0
  155. package/src/RaftTimezone.ts +151 -0
  156. package/src/RaftTypes.ts +34 -1
  157. package/src/RaftUpdateManager.ts +1 -1
  158. package/src/main.ts +2 -0
@@ -0,0 +1,76 @@
1
+ import React, { useRef } from 'react';
2
+ import ConnManager from './ConnManager';
3
+ import './styles.css';
4
+
5
+ const connManager = ConnManager.getInstance();
6
+
7
+ const LAST_SAMPLE_AVG_COUNT = 5;
8
+
9
+ export interface DeviceStatsPanelProps {
10
+ deviceKey: string;
11
+ lastUpdated: number;
12
+ }
13
+
14
+ const DeviceStatsPanel: React.FC<DeviceStatsPanelProps> = ({ deviceKey, lastUpdated }: DeviceStatsPanelProps) => {
15
+ const deviceManager = connManager.getConnector().getSystemType()?.deviceMgrIF;
16
+ const stats = deviceManager?.getDeviceStats(deviceKey);
17
+ const recentAgesRef = useRef<number[]>([]);
18
+
19
+ if (!stats) {
20
+ return <></>;
21
+ }
22
+
23
+ const windowSeconds = stats.windowMs / 1000;
24
+ const sampleRateHz = Number.isFinite(stats.sampleRateHz) ? stats.sampleRateHz : 0;
25
+ const nowMs = Math.max(lastUpdated || 0, Date.now());
26
+
27
+ let lastSampleAgeMs: number | null = null;
28
+ if (stats.lastSampleTimeMs) {
29
+ const ageMs = nowMs - stats.lastSampleTimeMs;
30
+ const ages = recentAgesRef.current;
31
+ ages.push(ageMs);
32
+ if (ages.length > LAST_SAMPLE_AVG_COUNT) {
33
+ ages.shift();
34
+ }
35
+ lastSampleAgeMs = ages.reduce((sum, v) => sum + v, 0) / ages.length;
36
+ }
37
+
38
+ const handleReset = () => {
39
+ deviceManager?.resetDeviceStats(deviceKey);
40
+ };
41
+
42
+ return (
43
+ <div className="device-stats-panel">
44
+ <div className="device-stats-header">
45
+ <span>Sampling Stats</span>
46
+ <button className="device-stats-reset" onClick={handleReset}>Reset Samples</button>
47
+ </div>
48
+ <div className="device-stats-grid">
49
+ <div className="device-stats-item">
50
+ <div className="device-stats-label">Sample Rate</div>
51
+ <div className="device-stats-value">{sampleRateHz.toFixed(2)} Hz</div>
52
+ </div>
53
+ <div className="device-stats-item">
54
+ <div className="device-stats-label">Window</div>
55
+ <div className="device-stats-value">{windowSeconds.toFixed(1)} s</div>
56
+ </div>
57
+ <div className="device-stats-item">
58
+ <div className="device-stats-label">Window Samples</div>
59
+ <div className="device-stats-value">{stats.windowSamples}</div>
60
+ </div>
61
+ <div className="device-stats-item">
62
+ <div className="device-stats-label">Total Samples</div>
63
+ <div className="device-stats-value">{stats.totalSamples}</div>
64
+ </div>
65
+ <div className="device-stats-item">
66
+ <div className="device-stats-label">Last Sample</div>
67
+ <div className="device-stats-value">
68
+ {lastSampleAgeMs === null ? 'N/A' : `${lastSampleAgeMs.toFixed(0)} ms ago`}
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ );
74
+ };
75
+
76
+ export default DeviceStatsPanel;
@@ -41,6 +41,17 @@ export default function DevicesPanel(props: DevicesPanelProps) {
41
41
  }
42
42
  deviceManager.addAttributeDataCallback(onNewAttributeData);
43
43
 
44
+ const onDeviceRemoved = (deviceKey: string, state: DeviceState) => {
45
+ setLastUpdated(Date.now());
46
+ };
47
+ deviceManager.addDeviceRemovedCallback(onDeviceRemoved);
48
+
49
+ return () => {
50
+ deviceManager.removeNewDeviceCallback(onNewDevice);
51
+ deviceManager.removeNewAttributeCallback(onNewAttribute);
52
+ deviceManager.removeAttributeDataCallback(onNewAttributeData);
53
+ deviceManager.removeDeviceRemovedCallback(onDeviceRemoved);
54
+ };
44
55
  }, []);
45
56
 
46
57
  const deviceManager = connManager.getConnector().getSystemType()?.deviceMgrIF;
@@ -0,0 +1,357 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import ConnManager from './ConnManager';
3
+ import { DeviceOnlineState, DeviceState, DevicesState } from '../../../src/RaftDeviceStates';
4
+ import DeviceSelectDialog, { DeviceLogEntry } from './DeviceSelectDialog';
5
+ import './styles.css';
6
+
7
+ const connManager = ConnManager.getInstance();
8
+
9
+ // Duration presets: label + milliseconds (0 = unlimited)
10
+ const DURATION_PRESETS = [
11
+ { label: '1 min', ms: 60000 },
12
+ { label: '5 min', ms: 300000 },
13
+ { label: '10 min', ms: 600000 },
14
+ { label: '30 min', ms: 1800000 },
15
+ { label: '1 hour', ms: 3600000 },
16
+ { label: '6 hours', ms: 21600000 },
17
+ { label: '24 hours', ms: 86400000 },
18
+ { label: 'Unlimited', ms: 0 },
19
+ ];
20
+
21
+ // Duration slider range (log scale): 1 min to 7 days
22
+ const DUR_MIN_MS = 60000;
23
+ const DUR_MAX_MS = 604800000;
24
+
25
+ function durationToSlider(ms: number): number {
26
+ if (ms <= 0) return 1; // unlimited = max
27
+ const minLog = Math.log10(DUR_MIN_MS);
28
+ const maxLog = Math.log10(DUR_MAX_MS);
29
+ return Math.min(1, Math.max(0, (Math.log10(Math.max(ms, DUR_MIN_MS)) - minLog) / (maxLog - minLog)));
30
+ }
31
+
32
+ function sliderToDuration(val: number): number {
33
+ if (val >= 0.99) return 0; // unlimited
34
+ const minLog = Math.log10(DUR_MIN_MS);
35
+ const maxLog = Math.log10(DUR_MAX_MS);
36
+ return Math.round(Math.pow(10, minLog + val * (maxLog - minLog)));
37
+ }
38
+
39
+ function formatDurationLabel(ms: number): string {
40
+ if (ms <= 0) return 'Unlimited';
41
+ if (ms < 60000) return `${(ms / 1000).toFixed(0)}s`;
42
+ if (ms < 3600000) return `${(ms / 60000).toFixed(1)} min`;
43
+ if (ms < 86400000) return `${(ms / 3600000).toFixed(1)} hr`;
44
+ return `${(ms / 86400000).toFixed(1)} days`;
45
+ }
46
+
47
+ export interface LogConfig {
48
+ format: string; // "csv" or "jsonl"
49
+ csvHeader?: boolean; // include metadata comment block in CSV
50
+ durationMs: number; // logging duration in ms (0 = unlimited)
51
+ devices: Array<{
52
+ bus: string;
53
+ addr: string;
54
+ rateMs: number;
55
+ attrs?: string[];
56
+ }>;
57
+ }
58
+
59
+ interface LogConfigPanelProps {
60
+ onConfigChanged?: (config: LogConfig | null) => void;
61
+ disabled?: boolean;
62
+ }
63
+
64
+ export default function LogConfigPanel({ onConfigChanged, disabled }: LogConfigPanelProps) {
65
+ const [deviceEntries, setDeviceEntries] = useState<DeviceLogEntry[]>([]);
66
+ const [lastUpdated, setLastUpdated] = useState(0);
67
+ const [format, setFormat] = useState<'csv' | 'jsonl'>('csv');
68
+ const [csvHeader, setCsvHeader] = useState(true);
69
+ const [durationMs, setDurationMs] = useState(600000); // 10 minutes default
70
+ const [fsFreeBytes, setFsFreeBytes] = useState<number | null>(null);
71
+
72
+ // Fetch filesystem info from device
73
+ const fetchFsInfo = async () => {
74
+ if (!connManager.getConnector().isConnected()) return;
75
+ try {
76
+ const resp = await connManager.getConnector().sendRICRESTMsg('filelist/local', {});
77
+ if (resp && typeof resp === 'object') {
78
+ const r = resp as any;
79
+ const size = r.diskSize ?? 0;
80
+ const used = r.diskUsed ?? 0;
81
+ if (size > 0) setFsFreeBytes(size - used);
82
+ }
83
+ } catch (e) {
84
+ // ignore
85
+ }
86
+ };
87
+
88
+ // Estimate bytes per minute based on current config
89
+ const estimateBytesPerMin = (): number => {
90
+ const enabledDevices = deviceEntries.filter(d => d.enabled);
91
+ if (enabledDevices.length === 0) return 0;
92
+
93
+ let totalBytesPerMin = 0;
94
+ for (const d of enabledDevices) {
95
+ // Effective rate: if 0 (max poll rate), use pollIntervalMs
96
+ const effectiveRateMs = d.rateMs > 0 ? d.rateMs : d.pollIntervalMs;
97
+ if (effectiveRateMs <= 0) continue;
98
+ const samplesPerMin = 60000 / effectiveRateMs;
99
+
100
+ // Estimate row size based on format
101
+ const numAttrs = d.selectedAttrs.length > 0 ? d.selectedAttrs.length : d.availableAttrs.length;
102
+ let bytesPerSample: number;
103
+ if (format === 'csv') {
104
+ // time field (~8 chars) + comma + ~8 chars per attr value + commas for other devices
105
+ bytesPerSample = 10 + numAttrs * 9 + enabledDevices.length * 2;
106
+ } else {
107
+ // JSONL: ~60 overhead + ~2 per raw byte (hex) * ~2 bytes per attr
108
+ bytesPerSample = 60 + numAttrs * 4;
109
+ }
110
+ totalBytesPerMin += samplesPerMin * bytesPerSample;
111
+ }
112
+ return totalBytesPerMin;
113
+ };
114
+
115
+ const bytesPerMin = estimateBytesPerMin();
116
+ const kbPerMin = bytesPerMin / 1024;
117
+ const maxDurationSecs = fsFreeBytes !== null && bytesPerMin > 0
118
+ ? (fsFreeBytes / bytesPerMin) * 60
119
+ : null;
120
+
121
+ // Refresh device list from device manager
122
+ const refreshDeviceList = () => {
123
+ const deviceManager = connManager.getConnector().getSystemType()?.deviceMgrIF;
124
+ if (!deviceManager) return;
125
+
126
+ const devicesState: DevicesState = deviceManager.getDevicesState();
127
+ const entries: DeviceLogEntry[] = [];
128
+
129
+ for (const [deviceKey, devState] of Object.entries(devicesState)) {
130
+ if (deviceKey === 'getDeviceKey') continue;
131
+ const ds = devState as DeviceState;
132
+ if (ds.onlineState !== DeviceOnlineState.Online) continue; // only online devices
133
+ if (ds.busName === '0') continue; // skip non-bus (direct-connected) devices
134
+
135
+ // Get poll interval from device type info
136
+ let pollIntervalMs = 50; // default
137
+ if (ds.deviceTypeInfo?.resp?.us) {
138
+ pollIntervalMs = ds.deviceTypeInfo.resp.us / 1000;
139
+ }
140
+
141
+ // Check if already in entries (preserve user's enabled/rate settings)
142
+ const existing = deviceEntries.find(
143
+ e => e.busName === ds.busName && e.addr === ds.deviceAddress
144
+ );
145
+
146
+ // Extract available attribute names from devInfoJson
147
+ const availableAttrs: string[] = [];
148
+ if (ds.deviceTypeInfo?.resp?.a) {
149
+ for (const attr of ds.deviceTypeInfo.resp.a) {
150
+ if (attr.n) availableAttrs.push(attr.n);
151
+ }
152
+ }
153
+
154
+ entries.push({
155
+ enabled: existing?.enabled ?? true,
156
+ busName: ds.busName,
157
+ addr: ds.deviceAddress,
158
+ typeName: ds.deviceTypeInfo?.name ?? ds.deviceType ?? 'Unknown',
159
+ rateMs: existing?.rateMs ?? 10000,
160
+ pollIntervalMs,
161
+ availableAttrs,
162
+ selectedAttrs: existing?.selectedAttrs ?? [],
163
+ });
164
+ }
165
+
166
+ setDeviceEntries(entries);
167
+ };
168
+
169
+ // Listen for device changes
170
+ useEffect(() => {
171
+ const deviceManager = connManager.getConnector().getSystemType()?.deviceMgrIF;
172
+ if (!deviceManager) return;
173
+
174
+ const onNewDevice = () => setLastUpdated(Date.now());
175
+ const onDeviceRemoved = () => setLastUpdated(Date.now());
176
+ deviceManager.addNewDeviceCallback(onNewDevice);
177
+ deviceManager.addDeviceRemovedCallback(onDeviceRemoved);
178
+
179
+ refreshDeviceList();
180
+
181
+ return () => {
182
+ deviceManager.removeNewDeviceCallback(onNewDevice);
183
+ deviceManager.removeDeviceRemovedCallback(onDeviceRemoved);
184
+ };
185
+ }, []);
186
+
187
+ // Refresh when devices change
188
+ useEffect(() => {
189
+ refreshDeviceList();
190
+ fetchFsInfo();
191
+ }, [lastUpdated]);
192
+
193
+ // Notify parent of config changes
194
+ useEffect(() => {
195
+ const enabledDevices = deviceEntries.filter(d => d.enabled);
196
+ if (enabledDevices.length === 0) {
197
+ onConfigChanged?.(null);
198
+ return;
199
+ }
200
+
201
+ const config: LogConfig = {
202
+ format,
203
+ csvHeader: format === 'csv' ? csvHeader : undefined,
204
+ durationMs,
205
+ devices: enabledDevices.map(d => {
206
+ const entry: LogConfig['devices'][0] = {
207
+ bus: d.busName,
208
+ addr: d.addr,
209
+ rateMs: d.rateMs,
210
+ };
211
+ if (format === 'csv' && d.selectedAttrs.length > 0) {
212
+ entry.attrs = d.selectedAttrs;
213
+ }
214
+ return entry;
215
+ }),
216
+ };
217
+ onConfigChanged?.(config);
218
+ }, [deviceEntries, format, csvHeader, durationMs]);
219
+
220
+ const [showDeviceDialog, setShowDeviceDialog] = useState(false);
221
+
222
+ const handleDeviceSave = (entries: DeviceLogEntry[]) => {
223
+ setDeviceEntries(entries);
224
+ setShowDeviceDialog(false);
225
+ };
226
+
227
+ // Build a short summary of what is being logged
228
+ const buildSummary = (): string => {
229
+ const enabled = deviceEntries.filter(d => d.enabled);
230
+ if (enabled.length === 0) return 'No devices selected';
231
+ return enabled.map(d => {
232
+ const attrInfo = d.selectedAttrs.length === 0
233
+ ? 'all attrs'
234
+ : `${d.selectedAttrs.length}/${d.availableAttrs.length} attrs`;
235
+ return `${d.typeName} (0x${d.addr}) — ${attrInfo}`;
236
+ }).join('\n');
237
+ };
238
+
239
+ if (deviceEntries.length === 0) {
240
+ return (
241
+ <div className="info-box log-config-panel">
242
+ <h3>Logging Settings</h3>
243
+ <p className="log-config-empty">No devices connected</p>
244
+ </div>
245
+ );
246
+ }
247
+
248
+ return (
249
+ <div className="info-box log-config-panel">
250
+ <h3>Logging Settings</h3>
251
+
252
+ <div className="log-config-format-row">
253
+ <label className="log-config-rate-label">Format:</label>
254
+ <select
255
+ className="log-config-mode-select"
256
+ value={format}
257
+ onChange={e => setFormat(e.target.value as 'csv' | 'jsonl')}
258
+ disabled={disabled}
259
+ >
260
+ <option value="csv">CSV (decoded)</option>
261
+ <option value="jsonl">JSONL (raw polls)</option>
262
+ </select>
263
+ {format === 'csv' && (
264
+ <label className="log-config-csv-header-label">
265
+ <input
266
+ type="checkbox"
267
+ checked={csvHeader}
268
+ onChange={e => setCsvHeader(e.target.checked)}
269
+ disabled={disabled}
270
+ />
271
+ Include metadata
272
+ </label>
273
+ )}
274
+ </div>
275
+
276
+ <div className="log-config-duration-row">
277
+ <label className="log-config-rate-label">Duration:</label>
278
+ <select
279
+ className="log-config-mode-select"
280
+ value={DURATION_PRESETS.find(p => p.ms === durationMs) ? durationMs : 'custom'}
281
+ onChange={e => {
282
+ const val = e.target.value;
283
+ if (val !== 'custom') setDurationMs(parseInt(val, 10));
284
+ }}
285
+ disabled={disabled}
286
+ >
287
+ {DURATION_PRESETS.map(p => (
288
+ <option key={p.ms} value={p.ms}>{p.label}</option>
289
+ ))}
290
+ {!DURATION_PRESETS.find(p => p.ms === durationMs) && (
291
+ <option value="custom">Custom</option>
292
+ )}
293
+ </select>
294
+ </div>
295
+
296
+ <div className="log-config-duration-slider-row">
297
+ <span className="log-config-slider-label">Short</span>
298
+ <input
299
+ type="range"
300
+ className="log-config-slider"
301
+ min="0"
302
+ max="1"
303
+ step="0.005"
304
+ value={durationToSlider(durationMs)}
305
+ onChange={e => {
306
+ const ms = sliderToDuration(parseFloat(e.target.value));
307
+ setDurationMs(ms);
308
+ }}
309
+ disabled={disabled}
310
+ />
311
+ <span className="log-config-slider-label">Long</span>
312
+ </div>
313
+
314
+ <div className="log-config-duration-display">
315
+ {formatDurationLabel(durationMs)}
316
+ </div>
317
+
318
+ {bytesPerMin > 0 && (
319
+ <div className="log-config-estimates">
320
+ <span>~{kbPerMin < 1 ? `${(kbPerMin * 1024).toFixed(0)} B/min` : kbPerMin < 1024 ? `${kbPerMin.toFixed(1)} KB/min` : `${(kbPerMin / 1024).toFixed(2)} MB/min`}</span>
321
+ {maxDurationSecs !== null && (
322
+ <span className="log-config-max-duration">
323
+ · Max: {formatDurationLabel(maxDurationSecs * 1000)} ({fsFreeBytes !== null ? `${(fsFreeBytes / 1024).toFixed(0)} KB free` : ''})
324
+ </span>
325
+ )}
326
+ </div>
327
+ )}
328
+
329
+ <div className="log-config-devices-summary">
330
+ <div className="log-config-summary-header">
331
+ <span className="log-config-rate-label">Devices:</span>
332
+ <button
333
+ className="log-config-edit-btn"
334
+ onClick={() => setShowDeviceDialog(true)}
335
+ disabled={disabled}
336
+ >
337
+ Edit…
338
+ </button>
339
+ </div>
340
+ <div className="log-config-summary-text">
341
+ {buildSummary().split('\n').map((line, i) => (
342
+ <div key={i}>{line}</div>
343
+ ))}
344
+ </div>
345
+ </div>
346
+
347
+ {showDeviceDialog && (
348
+ <DeviceSelectDialog
349
+ entries={deviceEntries}
350
+ format={format}
351
+ onSave={handleDeviceSave}
352
+ onCancel={() => setShowDeviceDialog(false)}
353
+ />
354
+ )}
355
+ </div>
356
+ );
357
+ }
@@ -0,0 +1,200 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import ConnManager from './ConnManager';
3
+ import './styles.css';
4
+
5
+ const connManager = ConnManager.getInstance();
6
+
7
+ interface LogFilesPanelProps {
8
+ refreshTrigger?: number;
9
+ onDownloadActiveChange?: (active: boolean) => void;
10
+ }
11
+
12
+ export default function LogFilesPanel({ refreshTrigger, onDownloadActiveChange }: LogFilesPanelProps) {
13
+ const [files, setFiles] = useState<{name: string, size: number}[]>([]);
14
+ const [diskSize, setDiskSize] = useState(0);
15
+ const [diskUsed, setDiskUsed] = useState(0);
16
+ const [isLoading, setIsLoading] = useState(false);
17
+ const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
18
+ const [downloadProgress, setDownloadProgress] = useState(0);
19
+ const [deletingFile, setDeletingFile] = useState<string | null>(null);
20
+ const [lastError, setLastError] = useState('');
21
+
22
+ const fetchFiles = async () => {
23
+ if (!connManager.getConnector().isConnected()) return;
24
+ setIsLoading(true);
25
+ setLastError('');
26
+ try {
27
+ // Request file listing for the logs folder on local filesystem
28
+ const resp = await connManager.getConnector().sendRICRESTMsg(
29
+ 'filelist/local/logs', {}
30
+ );
31
+ const fileList = typeof resp === 'string' ? JSON.parse(resp) : resp;
32
+ setFiles((fileList.files || []).sort((a: {name: string}, b: {name: string}) => b.name.localeCompare(a.name)));
33
+ setDiskSize(fileList.diskSize || 0);
34
+ setDiskUsed(fileList.diskUsed || 0);
35
+ } catch (e) {
36
+ console.warn('Failed to get file list', e);
37
+ setLastError('Failed to get file list');
38
+ }
39
+ setIsLoading(false);
40
+ };
41
+
42
+ useEffect(() => {
43
+ fetchFiles();
44
+ }, [refreshTrigger]);
45
+
46
+ const formatBytes = (bytes: number): string => {
47
+ if (bytes < 1024) return `${bytes} B`;
48
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
49
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
50
+ };
51
+
52
+ const handleDownload = async (file: {name: string, size: number}) => {
53
+ // Estimate download time and warn user for large transfers
54
+ const connMethod = connManager.getConnector().getConnMethod();
55
+ const isBLE = connMethod === 'WebBLE' || connMethod === 'PhoneBLE';
56
+ const estimatedBytesPerSec = isBLE ? 5000 : 50000;
57
+ const estimatedTimeSec = file.size / estimatedBytesPerSec;
58
+
59
+ if (estimatedTimeSec > 30) {
60
+ const timeStr = estimatedTimeSec >= 60
61
+ ? `${Math.round(estimatedTimeSec / 60)} min ${Math.round(estimatedTimeSec % 60)} sec`
62
+ : `${Math.round(estimatedTimeSec)} sec`;
63
+ const confirmed = window.confirm(
64
+ `Download ${file.name} (${formatBytes(file.size)})?\n\n` +
65
+ `Estimated time over ${connMethod}: ~${timeStr}\n\n` +
66
+ `Continue?`
67
+ );
68
+ if (!confirmed) return;
69
+ }
70
+
71
+ setDownloadingFile(file.name);
72
+ setDownloadProgress(0);
73
+ setLastError('');
74
+ onDownloadActiveChange?.(true);
75
+ try {
76
+ // Download from local/logs/<filename>
77
+ const filePath = `local/logs/${file.name}`;
78
+ const result = await connManager.getConnector().fsGetContents(
79
+ filePath,
80
+ 'fs',
81
+ (received: number, total: number) => {
82
+ if (total > 0) {
83
+ setDownloadProgress(Math.round((received / total) * 100));
84
+ }
85
+ }
86
+ );
87
+
88
+ if (result.downloadedOk && result.fileData) {
89
+ // Trigger browser download
90
+ const blob = new Blob([result.fileData], { type: 'application/octet-stream' });
91
+ const url = URL.createObjectURL(blob);
92
+ const a = document.createElement('a');
93
+ a.href = url;
94
+ a.download = file.name;
95
+ document.body.appendChild(a);
96
+ a.click();
97
+ document.body.removeChild(a);
98
+ URL.revokeObjectURL(url);
99
+ } else {
100
+ setLastError(`Failed to download ${file.name}`);
101
+ }
102
+ } catch (e) {
103
+ console.warn('Download failed', e);
104
+ setLastError(`Download error: ${file.name}`);
105
+ }
106
+ setDownloadingFile(null);
107
+ setDownloadProgress(0);
108
+ onDownloadActiveChange?.(false);
109
+ };
110
+
111
+ const handleDelete = async (file: {name: string, size: number}) => {
112
+ const confirmed = window.confirm(`Delete ${file.name} (${formatBytes(file.size)})?`);
113
+ if (!confirmed) return;
114
+
115
+ setDeletingFile(file.name);
116
+ setLastError('');
117
+ try {
118
+ const resp = await connManager.getConnector().sendRICRESTMsg(
119
+ `filedelete/local/logs/${file.name}`, {}
120
+ );
121
+ const r = resp as any;
122
+ if (r?.rslt === 'ok') {
123
+ await fetchFiles();
124
+ } else {
125
+ setLastError(`Failed to delete ${file.name}`);
126
+ }
127
+ } catch (e) {
128
+ console.warn('Delete failed', e);
129
+ setLastError(`Delete error: ${file.name}`);
130
+ }
131
+ setDeletingFile(null);
132
+ };
133
+
134
+ return (
135
+ <div className="info-box log-files-panel">
136
+ <div className="log-files-header">
137
+ <h3>Log Files</h3>
138
+ <button
139
+ className="log-files-refresh-button"
140
+ onClick={fetchFiles}
141
+ disabled={isLoading}
142
+ title="Refresh file list"
143
+ >
144
+
145
+ </button>
146
+ </div>
147
+
148
+ {diskSize > 0 && (
149
+ <div className="log-files-disk-info">
150
+ {formatBytes(diskUsed)} / {formatBytes(diskSize)} used
151
+ </div>
152
+ )}
153
+
154
+ {isLoading ? (
155
+ <div className="log-files-loading">Loading...</div>
156
+ ) : files.length === 0 ? (
157
+ <div className="log-files-empty">No log files found</div>
158
+ ) : (
159
+ <div className="log-files-list">
160
+ {files.map((file) => {
161
+ const isDownloading = downloadingFile === file.name;
162
+
163
+ return (
164
+ <div key={file.name} className="log-file-item">
165
+ <div className="log-file-info">
166
+ <div className="log-file-name" title={file.name}>
167
+ {file.name}
168
+ </div>
169
+ <div className="log-file-size">{formatBytes(file.size)}</div>
170
+ </div>
171
+ <div className="log-file-actions">
172
+ <button
173
+ className="log-file-download-button"
174
+ onClick={() => handleDownload(file)}
175
+ disabled={isDownloading || downloadingFile !== null || deletingFile !== null}
176
+ title={`Download ${file.name}`}
177
+ >
178
+ {isDownloading ? `${downloadProgress}%` : '⬇'}
179
+ </button>
180
+ <button
181
+ className="log-file-delete-button"
182
+ onClick={() => handleDelete(file)}
183
+ disabled={isDownloading || downloadingFile !== null || deletingFile === file.name}
184
+ title={`Delete ${file.name}`}
185
+ >
186
+ {deletingFile === file.name ? '...' : '🗑'}
187
+ </button>
188
+ </div>
189
+ </div>
190
+ );
191
+ })}
192
+ </div>
193
+ )}
194
+
195
+ {lastError && (
196
+ <div className="logging-error">{lastError}</div>
197
+ )}
198
+ </div>
199
+ );
200
+ }