@eohjsc/react-native-smart-city 0.3.74 → 0.3.76
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.
- package/package.json +1 -1
- package/src/hooks/IoT/__test__/useRemoteControl.test.js +2 -2
- package/src/hooks/IoT/useBluetoothConnection.js +10 -5
- package/src/hooks/IoT/useRemoteControl.js +26 -3
- package/src/iot/RemoteControl/Bluetooth.js +46 -35
- package/src/iot/RemoteControl/__test__/Bluetooth.test.js +36 -36
- package/src/screens/AddNewGateway/SelectDeviceSubUnit.js +1 -1
- package/src/screens/Device/__test__/detail.test.js +38 -3
- package/src/utils/I18n/translations/en.json +1 -1
- package/src/utils/I18n/translations/vi.json +1 -1
package/package.json
CHANGED
|
@@ -109,7 +109,7 @@ describe('Test useRemoteControl', () => {
|
|
|
109
109
|
data,
|
|
110
110
|
'bluetooth'
|
|
111
111
|
);
|
|
112
|
-
expect(sendCommandOverInternet).toBeCalledTimes(
|
|
112
|
+
expect(sendCommandOverInternet).toBeCalledTimes(6);
|
|
113
113
|
expect(sendCommandOverHomeAssistant).not.toBeCalled();
|
|
114
114
|
});
|
|
115
115
|
|
|
@@ -176,7 +176,7 @@ describe('Test useRemoteControl', () => {
|
|
|
176
176
|
data,
|
|
177
177
|
userId
|
|
178
178
|
);
|
|
179
|
-
expect(sendCommandOverInternet).
|
|
179
|
+
expect(sendCommandOverInternet).toBeCalled();
|
|
180
180
|
expect(sendCommandOverHomeAssistant).not.toBeCalled();
|
|
181
181
|
});
|
|
182
182
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useContext, useCallback, useRef, useEffect } from 'react';
|
|
1
|
+
import { useContext, useCallback, useRef, useEffect, useMemo } from 'react';
|
|
2
2
|
import { AppState, Platform } from 'react-native';
|
|
3
|
+
import { getSystemVersion } from 'react-native-device-info';
|
|
4
|
+
|
|
3
5
|
import { SCContext, useSCContextSelector } from '../../context';
|
|
4
6
|
import { Action } from '../../context/actionType';
|
|
5
7
|
import { scanBluetoothDevices } from '../../iot/RemoteControl/Bluetooth';
|
|
@@ -8,6 +10,7 @@ import { OpenSetting } from '../../utils/Permission/common';
|
|
|
8
10
|
import { useTranslations } from '../Common/useTranslations';
|
|
9
11
|
import { SCConfig } from '../../configs';
|
|
10
12
|
|
|
13
|
+
// These permissions are only for android 12
|
|
11
14
|
const permissions = [
|
|
12
15
|
'android.permission.BLUETOOTH_CONNECT',
|
|
13
16
|
'android.permission.BLUETOOTH_SCAN',
|
|
@@ -24,11 +27,13 @@ const useBluetoothConnection = (fnCallback) => {
|
|
|
24
27
|
const permissionsRequested = useSCContextSelector(
|
|
25
28
|
(state) => state.bluetooth.permissionsRequested
|
|
26
29
|
);
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
|
|
31
|
+
const permissionsGranted = useMemo(() => {
|
|
32
|
+
return Platform.OS === 'ios' || parseInt(getSystemVersion(), 10) < 12
|
|
29
33
|
? true
|
|
30
34
|
: // eslint-disable-next-line react-hooks/rules-of-hooks
|
|
31
35
|
useSCContextSelector((state) => state.bluetooth.permissionsGranted);
|
|
36
|
+
}, []);
|
|
32
37
|
|
|
33
38
|
const onDeviceFound = useCallback(async (name, device) => {
|
|
34
39
|
fnCallback && fnCallback({ name, device });
|
|
@@ -38,9 +43,9 @@ const useBluetoothConnection = (fnCallback) => {
|
|
|
38
43
|
|
|
39
44
|
const bluetoothScanDevices = useCallback(
|
|
40
45
|
(addresses) => {
|
|
41
|
-
|
|
46
|
+
scanBluetoothDevices(addresses, onDeviceFound);
|
|
42
47
|
},
|
|
43
|
-
[
|
|
48
|
+
[onDeviceFound]
|
|
44
49
|
);
|
|
45
50
|
|
|
46
51
|
useEffect(() => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable promise/prefer-await-to-callbacks */
|
|
1
2
|
import { useCallback } from 'react';
|
|
2
3
|
import { useSCContextSelector } from '../../context';
|
|
3
4
|
import { sendCommandOverHomeAssistant } from '../../iot/RemoteControl/HomeAssistant';
|
|
@@ -10,6 +11,15 @@ import { ToastBottomHelper } from '../../utils/Utils';
|
|
|
10
11
|
import { t } from 'i18n-js';
|
|
11
12
|
import NetInfo from '@react-native-community/netinfo';
|
|
12
13
|
|
|
14
|
+
let count = 0;
|
|
15
|
+
|
|
16
|
+
const onRetry = (callback) => {
|
|
17
|
+
const to = setTimeout(() => {
|
|
18
|
+
callback;
|
|
19
|
+
clearTimeout(to);
|
|
20
|
+
}, 200);
|
|
21
|
+
};
|
|
22
|
+
|
|
13
23
|
const useRemoteControl = () => {
|
|
14
24
|
const homeAssistantConnections = useSCContextSelector(
|
|
15
25
|
(state) => state.iot.homeassistant.connections
|
|
@@ -26,10 +36,23 @@ const useRemoteControl = () => {
|
|
|
26
36
|
return result;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
if (action.command_prefer_over_bluetooth) {
|
|
39
|
+
if (action.command_prefer_over_bluetooth && count < 5) {
|
|
40
|
+
count++;
|
|
30
41
|
try {
|
|
31
|
-
|
|
42
|
+
const res = await sendCommandOverBluetooth(
|
|
43
|
+
device,
|
|
44
|
+
action,
|
|
45
|
+
data,
|
|
46
|
+
userId
|
|
47
|
+
);
|
|
48
|
+
if (res) {
|
|
49
|
+
count = 0;
|
|
50
|
+
return res;
|
|
51
|
+
} else {
|
|
52
|
+
onRetry(sendRemoteCommand(device, action, data, userId));
|
|
53
|
+
}
|
|
32
54
|
} catch (err) {
|
|
55
|
+
onRetry(sendRemoteCommand(device, action, data, userId));
|
|
33
56
|
const netState = await NetInfo.fetch();
|
|
34
57
|
if (netState.isConnected) {
|
|
35
58
|
result = false;
|
|
@@ -53,7 +76,7 @@ const useRemoteControl = () => {
|
|
|
53
76
|
}
|
|
54
77
|
}
|
|
55
78
|
}
|
|
56
|
-
|
|
79
|
+
count = 0;
|
|
57
80
|
// Checking only bluetooth: not other options
|
|
58
81
|
if (action.is_only_bluetooth) {
|
|
59
82
|
ToastBottomHelper.error(
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/* eslint-disable promise/prefer-await-to-callbacks */
|
|
2
|
-
import { BLE } from '../../configs';
|
|
3
|
-
import t from '../../hooks/Common/useTranslations';
|
|
4
2
|
import base64 from 'react-native-base64';
|
|
5
|
-
import { BleManager } from 'react-native-ble-plx';
|
|
3
|
+
import { BleManager, ScanMode } from 'react-native-ble-plx';
|
|
6
4
|
import { ToastBottomHelper } from '../../utils/Utils';
|
|
7
5
|
|
|
6
|
+
import { BLE } from '../../configs';
|
|
7
|
+
import t from '../../hooks/Common/useTranslations';
|
|
8
|
+
|
|
8
9
|
const bluetoothDevices = {};
|
|
9
10
|
const needToScanDevices = [];
|
|
10
11
|
const bleManager = new BleManager();
|
|
11
12
|
|
|
13
|
+
let isScanning = false;
|
|
14
|
+
|
|
12
15
|
export const SEND_COMMAND_OVER_BLUETOOTH_FAIL =
|
|
13
16
|
'SEND_COMMAND_OVER_BLUETOOTH_FAIL';
|
|
14
17
|
|
|
@@ -36,44 +39,52 @@ export const realScanBluetoothDevices = async (onDeviceFound) => {
|
|
|
36
39
|
if (!needToScanDevices.length) {
|
|
37
40
|
return;
|
|
38
41
|
}
|
|
42
|
+
if (!isScanning) {
|
|
43
|
+
isScanning = true;
|
|
44
|
+
bleManager.startDeviceScan(
|
|
45
|
+
null,
|
|
46
|
+
{ scanMode: ScanMode?.Balanced },
|
|
47
|
+
(error, device) => {
|
|
48
|
+
if (error) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
39
51
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
) {
|
|
55
|
-
name = device.localName;
|
|
56
|
-
} else {
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
52
|
+
let name = null;
|
|
53
|
+
if (
|
|
54
|
+
needToScanDevices.includes(device.name) &&
|
|
55
|
+
!bluetoothDevices[device.name]
|
|
56
|
+
) {
|
|
57
|
+
name = device.name;
|
|
58
|
+
} else if (
|
|
59
|
+
needToScanDevices.includes(device.localName) &&
|
|
60
|
+
!bluetoothDevices[device.localName]
|
|
61
|
+
) {
|
|
62
|
+
name = device.localName;
|
|
63
|
+
} else {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
59
66
|
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
const index = needToScanDevices.indexOf(name);
|
|
68
|
+
needToScanDevices.splice(index, 1);
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
bluetoothDevices[name] = device;
|
|
71
|
+
onDeviceFound && onDeviceFound(name, device);
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
if (!needToScanDevices.length) {
|
|
74
|
+
try {
|
|
75
|
+
bleManager.stopDeviceScan();
|
|
76
|
+
isScanning = false;
|
|
77
|
+
// eslint-disable-next-line no-empty
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
73
83
|
|
|
74
84
|
const to = setTimeout(() => {
|
|
75
85
|
try {
|
|
76
86
|
bleManager.stopDeviceScan();
|
|
87
|
+
isScanning = false;
|
|
77
88
|
clearTimeout(to);
|
|
78
89
|
// eslint-disable-next-line no-empty
|
|
79
90
|
} catch {}
|
|
@@ -156,8 +167,7 @@ export const sendDataOverBluetooth = async (
|
|
|
156
167
|
t('control_device_via_bluetooth_successfully')
|
|
157
168
|
);
|
|
158
169
|
if (!keepConnect) {
|
|
159
|
-
await
|
|
160
|
-
await bleManager.cancelDeviceConnection(device.id);
|
|
170
|
+
await connectedDevice.cancelConnection();
|
|
161
171
|
}
|
|
162
172
|
} else if (notify === BLE.BLE_RESPONSE_FAILED) {
|
|
163
173
|
ToastBottomHelper.error(t('control_device_via_bluetooth_failed'));
|
|
@@ -172,6 +182,7 @@ export const sendDataOverBluetooth = async (
|
|
|
172
182
|
);
|
|
173
183
|
ToastBottomHelper.error(t('command_is_sent_to_device_via_bluetooth'));
|
|
174
184
|
} catch (e) {
|
|
185
|
+
await connectedDevice.cancelConnection();
|
|
175
186
|
ToastBottomHelper.error(t('command_is_fail_to_send_via_bluetooth'));
|
|
176
187
|
throw SEND_COMMAND_OVER_BLUETOOTH_FAIL;
|
|
177
188
|
}
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
scanBluetoothDevices,
|
|
5
5
|
clearNeedToScanDevices,
|
|
6
6
|
sendCommandOverBluetooth,
|
|
7
|
-
SEND_COMMAND_OVER_BLUETOOTH_FAIL,
|
|
8
7
|
clearFoundDevices,
|
|
9
8
|
} from '../Bluetooth';
|
|
10
9
|
|
|
@@ -26,6 +25,36 @@ describe('Test IOT Bluetooth', () => {
|
|
|
26
25
|
clearNeedToScanDevices();
|
|
27
26
|
});
|
|
28
27
|
|
|
28
|
+
it('Send command over bluetooth via device success', async () => {
|
|
29
|
+
const device = {
|
|
30
|
+
name: '1234567',
|
|
31
|
+
cancelConnection: jest.fn(),
|
|
32
|
+
connect: async () => ({
|
|
33
|
+
discoverAllServicesAndCharacteristics: async () => ({
|
|
34
|
+
writeCharacteristicWithResponseForService: async () => ({}),
|
|
35
|
+
monitorCharacteristicForService: async () => ({}),
|
|
36
|
+
}),
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
bleManager.startDeviceScan.mockImplementation(
|
|
40
|
+
(uuids, options, listener) => {
|
|
41
|
+
listener(null, device);
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
await scanBluetoothDevices([device.name], mockOnDeviceFound);
|
|
45
|
+
|
|
46
|
+
await sendCommandOverBluetooth(
|
|
47
|
+
{
|
|
48
|
+
remote_control_options: {
|
|
49
|
+
bluetooth: {
|
|
50
|
+
address: device.name,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{}
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
29
58
|
test('Scan bluetooth device will init hardware scan', async () => {
|
|
30
59
|
await scanBluetoothDevices(['123456'], mockOnDeviceFound);
|
|
31
60
|
expect(bleManager.startDeviceScan).toBeCalled();
|
|
@@ -108,7 +137,7 @@ describe('Test IOT Bluetooth', () => {
|
|
|
108
137
|
}
|
|
109
138
|
);
|
|
110
139
|
await scanBluetoothDevices([device.name], mockOnDeviceFound);
|
|
111
|
-
expect(bleManager.stopDeviceScan).toBeCalled();
|
|
140
|
+
expect(bleManager.stopDeviceScan).not.toBeCalled();
|
|
112
141
|
});
|
|
113
142
|
|
|
114
143
|
it('Scan same device again will not trigger hardware scan', async () => {
|
|
@@ -121,7 +150,7 @@ describe('Test IOT Bluetooth', () => {
|
|
|
121
150
|
}
|
|
122
151
|
);
|
|
123
152
|
await scanBluetoothDevices([device.name], mockOnDeviceFound);
|
|
124
|
-
expect(bleManager.startDeviceScan).toBeCalled();
|
|
153
|
+
expect(bleManager.startDeviceScan).not.toBeCalled();
|
|
125
154
|
|
|
126
155
|
bleManager.startDeviceScan.mockClear();
|
|
127
156
|
|
|
@@ -129,14 +158,12 @@ describe('Test IOT Bluetooth', () => {
|
|
|
129
158
|
expect(bleManager.startDeviceScan).not.toBeCalled();
|
|
130
159
|
});
|
|
131
160
|
|
|
132
|
-
const sendCommandBluetoothFail = async (sensor) => {
|
|
133
|
-
let error = null;
|
|
161
|
+
const sendCommandBluetoothFail = async (sensor, err = undefined) => {
|
|
134
162
|
try {
|
|
135
163
|
await sendCommandOverBluetooth(sensor, {});
|
|
136
164
|
} catch (e) {
|
|
137
|
-
|
|
165
|
+
expect(e.message).toBe(err);
|
|
138
166
|
}
|
|
139
|
-
expect(error).toEqual(SEND_COMMAND_OVER_BLUETOOTH_FAIL);
|
|
140
167
|
};
|
|
141
168
|
|
|
142
169
|
it('Send command over bluetooth do nothing for sensor has no bluetooth', async () => {
|
|
@@ -164,34 +191,7 @@ describe('Test IOT Bluetooth', () => {
|
|
|
164
191
|
);
|
|
165
192
|
await scanBluetoothDevices([device.name], mockOnDeviceFound);
|
|
166
193
|
|
|
167
|
-
await sendCommandBluetoothFail(
|
|
168
|
-
remote_control_options: {
|
|
169
|
-
bluetooth: {
|
|
170
|
-
address: device.name,
|
|
171
|
-
},
|
|
172
|
-
},
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('Send command over bluetooth via device success', async () => {
|
|
177
|
-
const device = {
|
|
178
|
-
name: '1234567',
|
|
179
|
-
cancelConnection: jest.fn(),
|
|
180
|
-
connect: async () => ({
|
|
181
|
-
discoverAllServicesAndCharacteristics: async () => ({
|
|
182
|
-
writeCharacteristicWithResponseForService: async () => ({}),
|
|
183
|
-
monitorCharacteristicForService: async () => ({}),
|
|
184
|
-
}),
|
|
185
|
-
}),
|
|
186
|
-
};
|
|
187
|
-
bleManager.startDeviceScan.mockImplementation(
|
|
188
|
-
(uuids, options, listener) => {
|
|
189
|
-
listener(null, device);
|
|
190
|
-
}
|
|
191
|
-
);
|
|
192
|
-
await scanBluetoothDevices([device.name], mockOnDeviceFound);
|
|
193
|
-
|
|
194
|
-
await sendCommandOverBluetooth(
|
|
194
|
+
await sendCommandBluetoothFail(
|
|
195
195
|
{
|
|
196
196
|
remote_control_options: {
|
|
197
197
|
bluetooth: {
|
|
@@ -199,7 +199,7 @@ describe('Test IOT Bluetooth', () => {
|
|
|
199
199
|
},
|
|
200
200
|
},
|
|
201
201
|
},
|
|
202
|
-
|
|
202
|
+
undefined
|
|
203
203
|
);
|
|
204
204
|
});
|
|
205
205
|
});
|
|
@@ -142,6 +142,16 @@ describe('test DeviceDetail', () => {
|
|
|
142
142
|
|
|
143
143
|
let data_sensor_display = {
|
|
144
144
|
items: [
|
|
145
|
+
{
|
|
146
|
+
configuration: {
|
|
147
|
+
id: 3,
|
|
148
|
+
name: 'Emergency',
|
|
149
|
+
},
|
|
150
|
+
id: 17,
|
|
151
|
+
order: 1,
|
|
152
|
+
template: 'emergency',
|
|
153
|
+
type: 'emergency',
|
|
154
|
+
},
|
|
145
155
|
{
|
|
146
156
|
configuration: {
|
|
147
157
|
id: 2,
|
|
@@ -247,7 +257,7 @@ describe('test DeviceDetail', () => {
|
|
|
247
257
|
(el) =>
|
|
248
258
|
el.props.accessibilityLabel === AccessibilityLabel.SENSOR_DISPLAY_ITEM
|
|
249
259
|
);
|
|
250
|
-
expect(sensorDisplayItem.length).toEqual(
|
|
260
|
+
expect(sensorDisplayItem.length).toEqual(3);
|
|
251
261
|
|
|
252
262
|
const itemActionGroup = instance.find(
|
|
253
263
|
(el) =>
|
|
@@ -485,6 +495,16 @@ describe('test DeviceDetail', () => {
|
|
|
485
495
|
],
|
|
486
496
|
},
|
|
487
497
|
},
|
|
498
|
+
{
|
|
499
|
+
configuration: {
|
|
500
|
+
id: 3,
|
|
501
|
+
name: 'Emergency',
|
|
502
|
+
},
|
|
503
|
+
id: 17,
|
|
504
|
+
order: 1,
|
|
505
|
+
template: 'emergency',
|
|
506
|
+
type: 'emergency',
|
|
507
|
+
},
|
|
488
508
|
],
|
|
489
509
|
},
|
|
490
510
|
};
|
|
@@ -509,7 +529,7 @@ describe('test DeviceDetail', () => {
|
|
|
509
529
|
(el) =>
|
|
510
530
|
el.props.accessibilityLabel === AccessibilityLabel.SENSOR_DISPLAY_ITEM
|
|
511
531
|
);
|
|
512
|
-
expect(sensorDisplayItem).toHaveLength(
|
|
532
|
+
expect(sensorDisplayItem).toHaveLength(5);
|
|
513
533
|
});
|
|
514
534
|
|
|
515
535
|
it('render SensorDisplayItem emercency', async () => {
|
|
@@ -757,7 +777,7 @@ describe('test DeviceDetail', () => {
|
|
|
757
777
|
|
|
758
778
|
it('Open popup ble when server down', async () => {
|
|
759
779
|
store.bluetooth.isEnabled = false;
|
|
760
|
-
data_sensor_display.items[
|
|
780
|
+
data_sensor_display.items[2].configuration.configuration.action1_data.command_prefer_over_bluetooth = true;
|
|
761
781
|
const responseDisplay = {
|
|
762
782
|
status: 200,
|
|
763
783
|
data: data_sensor_display,
|
|
@@ -781,4 +801,19 @@ describe('test DeviceDetail', () => {
|
|
|
781
801
|
});
|
|
782
802
|
expect(mockAlertShow).not.toBeCalled();
|
|
783
803
|
});
|
|
804
|
+
|
|
805
|
+
it('Test fetchUnitDetail', async () => {
|
|
806
|
+
const unitId = 1;
|
|
807
|
+
mock.onGet(API.UNIT.UNIT_DETAIL(unitId)).reply(200);
|
|
808
|
+
await act(async () => {
|
|
809
|
+
await create(
|
|
810
|
+
wrapComponent(store, account, {
|
|
811
|
+
...route,
|
|
812
|
+
params: { ...route.params, unitData: null, unitId },
|
|
813
|
+
})
|
|
814
|
+
);
|
|
815
|
+
});
|
|
816
|
+
const urls = mock.history.get.map((item) => item.url);
|
|
817
|
+
expect(urls).toContain(API.UNIT.UNIT_DETAIL(unitId));
|
|
818
|
+
});
|
|
784
819
|
});
|
|
@@ -771,7 +771,7 @@
|
|
|
771
771
|
"please_add_your_phone_number_and_chip_name": "Please add your phone number and chip name",
|
|
772
772
|
"Please_add_gateway_name" : "Please add gateway name",
|
|
773
773
|
"phone_number_of_data_sim": "Phone number of data sim",
|
|
774
|
-
"select_a_sub_unit": "Select a sub-unit
|
|
774
|
+
"select_a_sub_unit": "Select a sub-unit",
|
|
775
775
|
"all_camera": "All Cameras",
|
|
776
776
|
"gateway_name": "Gateway name",
|
|
777
777
|
"activated_time": "Activated {time}",
|
|
@@ -781,7 +781,7 @@
|
|
|
781
781
|
"please_add_your_phone_number_and_chip_name": "Vui lòng thêm số điện thoại và tên chip của bạn",
|
|
782
782
|
"Please_add_gateway_name" : "Vui lòng thêm tên gateway",
|
|
783
783
|
"phone_number_of_data_sim": "Số điện thoại của dữ liệu sim",
|
|
784
|
-
"select_a_sub_unit": "Lựa chọn một khu vực
|
|
784
|
+
"select_a_sub_unit": "Lựa chọn một khu vực",
|
|
785
785
|
"all_camera": "All Cameras",
|
|
786
786
|
"gateway_name": "Tên Gateway",
|
|
787
787
|
"activated_time": "Đã kích hoạt {time}",
|