- fix manual bind stuck at BLE scan, redirect to bind-success - add 15s timeout and stopScan to ble-connect - add stopScan method to ble.js - add back-to-home button on bind-success page - fix field name in subscribe-plans (plan -> plan_type) - fix history.js to handle created_at field - fix index.js to handle device name field - add subscription route alias in request.js - extend admin cloud function from 1 to 9 actions - add mock mode to admin-console request.js - remove unused discover page and legacy record function Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
627 行
16 KiB
JavaScript
627 行
16 KiB
JavaScript
var SERVICE = {
|
|
DEVICE_INFO: 'FFE0',
|
|
DATA_COMM: 'FFE1',
|
|
OTA: 'FFE2'
|
|
}
|
|
|
|
var CHAR = {
|
|
DEVICE_INFO: 'FFE3',
|
|
COMMAND: 'FFE4',
|
|
STATUS: 'FFE5',
|
|
BOND_INFO: 'FFE6',
|
|
OTA_CONTROL: 'FFE7',
|
|
OTA_DATA: 'FFE8',
|
|
OTA_STATUS: 'FFE9'
|
|
}
|
|
|
|
var CMD = {
|
|
SET_PARAMS: 0x01,
|
|
START: 0x02,
|
|
STOP: 0x03,
|
|
QUERY_STATUS: 0x04,
|
|
BIND: 0x05,
|
|
UNBIND: 0x06
|
|
}
|
|
|
|
var NOTIFY = {
|
|
STATUS_REPORT: 0x21,
|
|
ACK: 0x22,
|
|
TREATMENT_COMPLETE: 0x31,
|
|
EXCEPTION: 0x32,
|
|
BIND_SUCCESS: 0x33
|
|
}
|
|
|
|
var MODE_STATE = {
|
|
IDLE: 0x00,
|
|
SCANNING: 0x01,
|
|
ACTIVE: 0x02,
|
|
PAUSED: 0x03,
|
|
COMPLETED: 0x04,
|
|
ERROR: 0x05,
|
|
OTA: 0x06
|
|
}
|
|
|
|
var WAVELENGTH = {
|
|
IR: 1,
|
|
R: 2,
|
|
UV: 3,
|
|
Y: 4
|
|
}
|
|
|
|
var TREAT_MODE = {
|
|
NORMAL: 0,
|
|
SMART: 1
|
|
}
|
|
|
|
var REGION = {
|
|
LEFT_CHEEK: 0x01,
|
|
RIGHT_CHEEK: 0x02,
|
|
FOREHEAD: 0x04,
|
|
CHIN: 0x08,
|
|
NOSE: 0x10,
|
|
LEFT_EYE: 0x20,
|
|
RIGHT_EYE: 0x40,
|
|
FULL_FACE: 0x7F
|
|
}
|
|
|
|
var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye']
|
|
|
|
var DEVICE_ERR = {
|
|
0x00: 'SUCCESS',
|
|
0x01: 'ERR_REGION_INVALID',
|
|
0x02: 'ERR_REGION_EMPTY',
|
|
0x03: 'ERR_BRIGHTNESS_INVALID',
|
|
0x04: 'ERR_DURATION_INVALID',
|
|
0x05: 'ERR_NOT_BOUND',
|
|
0x06: 'ERR_NO_SUBSCRIPTION',
|
|
0x07: 'ERR_TEMP_HIGH',
|
|
0x08: 'ERR_BATTERY_LOW',
|
|
0x09: 'ERR_ALREADY_RUNNING',
|
|
0x0A: 'ERR_NOT_RUNNING',
|
|
0x0B: 'ERR_OTA_FAILED',
|
|
0x0C: 'ERR_BLE_DISCONNECTED'
|
|
}
|
|
|
|
var _deviceId = null
|
|
var _connected = false
|
|
var _chars = {}
|
|
var _cmdSeq = 0
|
|
var _pendingAcks = {}
|
|
var _listeners = {}
|
|
|
|
function nextSeq() {
|
|
_cmdSeq = (_cmdSeq + 1) % 256
|
|
return _cmdSeq
|
|
}
|
|
|
|
function bufferToBytes(buffer) {
|
|
var arr = new Uint8Array(buffer)
|
|
var bytes = []
|
|
for (var i = 0; i < arr.length; i++) {
|
|
bytes.push(arr[i])
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
function bytesToBuffer(bytes) {
|
|
var buffer = new ArrayBuffer(bytes.length)
|
|
var view = new Uint8Array(buffer)
|
|
for (var i = 0; i < bytes.length; i++) {
|
|
view[i] = bytes[i]
|
|
}
|
|
return buffer
|
|
}
|
|
|
|
function xorChecksum(bytes) {
|
|
var result = 0
|
|
for (var i = 0; i < bytes.length; i++) {
|
|
result ^= bytes[i]
|
|
}
|
|
return result
|
|
}
|
|
|
|
function buildFrame(type, payload) {
|
|
var len = payload ? payload.length : 0
|
|
var frame = [0xAA, 0x55, len, type]
|
|
if (payload && payload.length > 0) {
|
|
frame = frame.concat(payload)
|
|
}
|
|
var checkBytes = frame.slice(2)
|
|
frame.push(xorChecksum(checkBytes))
|
|
return bytesToBuffer(frame)
|
|
}
|
|
|
|
function parseFrame(buffer) {
|
|
var bytes = bufferToBytes(buffer)
|
|
if (bytes.length < 5) return null
|
|
if (bytes[0] !== 0xAA || bytes[1] !== 0x55) return null
|
|
var len = bytes[2]
|
|
if (bytes.length < 5 + len) return null
|
|
var type = bytes[3]
|
|
var payload = bytes.slice(4, 4 + len)
|
|
var checksum = bytes[4 + len]
|
|
var expected = xorChecksum(bytes.slice(2, 4 + len))
|
|
if (checksum !== expected) return null
|
|
return { type: type, payload: payload, seq: payload.length > 0 ? payload[payload.length - 1] : 0 }
|
|
}
|
|
|
|
function uint32ToBytes(value) {
|
|
return [
|
|
(value >> 24) & 0xFF,
|
|
(value >> 16) & 0xFF,
|
|
(value >> 8) & 0xFF,
|
|
value & 0xFF
|
|
]
|
|
}
|
|
|
|
function bytesToUint32(bytes, offset) {
|
|
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]
|
|
}
|
|
|
|
function hexToBytes(hex) {
|
|
var bytes = []
|
|
for (var i = 0; i < hex.length; i += 2) {
|
|
bytes.push(parseInt(hex.substr(i, 2), 16))
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
function bytesToHex(bytes) {
|
|
var hex = ''
|
|
for (var i = 0; i < bytes.length; i++) {
|
|
hex += ('0' + bytes[i].toString(16)).slice(-2)
|
|
}
|
|
return hex.toUpperCase()
|
|
}
|
|
|
|
function findCharUuid(chars, shortUuid) {
|
|
for (var i = 0; i < chars.length; i++) {
|
|
if (chars[i].uuid.indexOf(shortUuid) !== -1) {
|
|
return chars[i].uuid
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function on(event, callback) {
|
|
if (!_listeners[event]) _listeners[event] = []
|
|
_listeners[event].push(callback)
|
|
}
|
|
|
|
function off(event, callback) {
|
|
if (!_listeners[event]) return
|
|
if (callback) {
|
|
_listeners[event] = _listeners[event].filter(function (cb) { return cb !== callback })
|
|
} else {
|
|
_listeners[event] = []
|
|
}
|
|
}
|
|
|
|
function emit(event, data) {
|
|
if (!_listeners[event]) return
|
|
_listeners[event].forEach(function (cb) {
|
|
try { cb(data) } catch (e) { console.error('ble emit error:', e) }
|
|
})
|
|
}
|
|
|
|
function handleNotification(frame) {
|
|
switch (frame.type) {
|
|
case NOTIFY.STATUS_REPORT:
|
|
emit('status', parseStatusReport(frame.payload))
|
|
break
|
|
case NOTIFY.ACK:
|
|
var ack = parseAck(frame.payload)
|
|
emit('ack', ack)
|
|
if (_pendingAcks[ack.seq]) {
|
|
if (ack.error_code === 0) {
|
|
_pendingAcks[ack.seq].resolve(ack)
|
|
} else {
|
|
_pendingAcks[ack.seq].reject(ack)
|
|
}
|
|
delete _pendingAcks[ack.seq]
|
|
}
|
|
break
|
|
case NOTIFY.TREATMENT_COMPLETE:
|
|
emit('treatment_complete', parseTreatmentComplete(frame.payload))
|
|
break
|
|
case NOTIFY.EXCEPTION:
|
|
emit('exception', parseException(frame.payload))
|
|
break
|
|
case NOTIFY.BIND_SUCCESS:
|
|
emit('bind_result', { success: frame.payload[0] === 0x00 })
|
|
break
|
|
}
|
|
}
|
|
|
|
function parseStatusReport(payload) {
|
|
if (payload.length < 14) return null
|
|
return {
|
|
mode_state: payload[0],
|
|
region_mask: payload[1],
|
|
wavelength: payload[2],
|
|
brightness: payload[3],
|
|
remaining_ms: bytesToUint32(payload, 4),
|
|
error_code: payload[8],
|
|
command_seq: payload[9],
|
|
battery: payload[10],
|
|
temperature: payload[11],
|
|
bind_status: payload[12],
|
|
subscription: payload[13]
|
|
}
|
|
}
|
|
|
|
function parseAck(payload) {
|
|
return {
|
|
seq: payload[0],
|
|
error_code: payload.length > 1 ? payload[1] : 0,
|
|
error_msg: DEVICE_ERR[payload.length > 1 ? payload[1] : 0] || 'UNKNOWN'
|
|
}
|
|
}
|
|
|
|
function parseTreatmentComplete(payload) {
|
|
return {
|
|
session_id: bytesToHex(payload.slice(0, 8)),
|
|
regions: payload[8],
|
|
total_duration_ms: bytesToUint32(payload, 9),
|
|
avg_pd: payload[13]
|
|
}
|
|
}
|
|
|
|
function parseException(payload) {
|
|
return {
|
|
error_code: payload[0],
|
|
error_msg: DEVICE_ERR[payload[0]] || 'UNKNOWN',
|
|
temperature: payload.length > 1 ? payload[1] : 0
|
|
}
|
|
}
|
|
|
|
function isConnected() {
|
|
return _connected && _deviceId !== null
|
|
}
|
|
|
|
function getDeviceId() {
|
|
return _deviceId
|
|
}
|
|
|
|
function startScan(callbacks) {
|
|
wx.openBluetoothAdapter({
|
|
success: function () {
|
|
wx.startBluetoothDevicesDiscovery({
|
|
allowDuplicatesKey: false,
|
|
success: function () {
|
|
wx.onBluetoothDeviceFound(function (res) {
|
|
var devices = res.devices || []
|
|
for (var i = 0; i < devices.length; i++) {
|
|
var d = devices[i]
|
|
var name = (d.name || '').toUpperCase()
|
|
var localName = (d.localName || '').toUpperCase()
|
|
if (name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 ||
|
|
name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1) {
|
|
wx.stopBluetoothDevicesDiscovery({})
|
|
if (callbacks.onFound) callbacks.onFound(d)
|
|
connect(d.deviceId, callbacks)
|
|
return
|
|
}
|
|
}
|
|
})
|
|
},
|
|
fail: function () {
|
|
if (callbacks.onError) callbacks.onError({ msg: '扫描失败' })
|
|
}
|
|
})
|
|
},
|
|
fail: function () {
|
|
if (callbacks.onError) callbacks.onError({ msg: '请开启蓝牙' })
|
|
}
|
|
})
|
|
}
|
|
|
|
function connect(deviceId, callbacks) {
|
|
_deviceId = deviceId
|
|
_chars = {}
|
|
|
|
wx.createBLEConnection({
|
|
deviceId: deviceId,
|
|
timeout: 10000,
|
|
success: function () {
|
|
_connected = true
|
|
discoverServices(deviceId, callbacks)
|
|
},
|
|
fail: function () {
|
|
_connected = false
|
|
if (callbacks.onError) callbacks.onError({ msg: '连接失败' })
|
|
}
|
|
})
|
|
}
|
|
|
|
function discoverServices(deviceId, callbacks) {
|
|
wx.getBLEDeviceServices({
|
|
deviceId: deviceId,
|
|
success: function (res) {
|
|
var services = res.services
|
|
var serviceMap = {}
|
|
for (var i = 0; i < services.length; i++) {
|
|
var uuid = services[i].uuid.toUpperCase()
|
|
if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
|
|
serviceMap.deviceInfo = services[i].uuid
|
|
} else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
|
|
serviceMap.dataComm = services[i].uuid
|
|
} else if (uuid.indexOf(SERVICE.OTA) !== -1) {
|
|
serviceMap.ota = services[i].uuid
|
|
}
|
|
}
|
|
|
|
var tasks = []
|
|
if (serviceMap.deviceInfo) {
|
|
tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
|
|
}
|
|
if (serviceMap.dataComm) {
|
|
tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
|
|
}
|
|
|
|
Promise.all(tasks).then(function () {
|
|
subscribeToNotifications(deviceId, serviceMap.dataComm).then(function () {
|
|
if (callbacks.onConnected) callbacks.onConnected({ deviceId: deviceId })
|
|
})
|
|
})
|
|
},
|
|
fail: function () {
|
|
if (callbacks.onError) callbacks.onError({ msg: '服务发现失败' })
|
|
}
|
|
})
|
|
}
|
|
|
|
function discoverChars(deviceId, serviceId, group) {
|
|
return new Promise(function (resolve) {
|
|
wx.getBLEDeviceCharacteristics({
|
|
deviceId: deviceId,
|
|
serviceId: serviceId,
|
|
success: function (res) {
|
|
var chars = res.characteristics || []
|
|
for (var i = 0; i < chars.length; i++) {
|
|
var c = chars[i]
|
|
var uuid = c.uuid.toUpperCase()
|
|
if (uuid.indexOf(CHAR.DEVICE_INFO) !== -1) _chars.deviceInfo = { uuid: c.uuid, serviceId: serviceId }
|
|
if (uuid.indexOf(CHAR.COMMAND) !== -1) _chars.command = { uuid: c.uuid, serviceId: serviceId }
|
|
if (uuid.indexOf(CHAR.STATUS) !== -1) _chars.status = { uuid: c.uuid, serviceId: serviceId }
|
|
if (uuid.indexOf(CHAR.BOND_INFO) !== -1) _chars.bondInfo = { uuid: c.uuid, serviceId: serviceId }
|
|
if (uuid.indexOf(CHAR.OTA_CONTROL) !== -1) _chars.otaControl = { uuid: c.uuid, serviceId: serviceId }
|
|
if (uuid.indexOf(CHAR.OTA_DATA) !== -1) _chars.otaData = { uuid: c.uuid, serviceId: serviceId }
|
|
if (uuid.indexOf(CHAR.OTA_STATUS) !== -1) _chars.otaStatus = { uuid: c.uuid, serviceId: serviceId }
|
|
}
|
|
resolve()
|
|
},
|
|
fail: function () { resolve() }
|
|
})
|
|
})
|
|
}
|
|
|
|
function subscribeToNotifications(deviceId, serviceId) {
|
|
return new Promise(function (resolve) {
|
|
if (!_chars.status) { resolve(); return }
|
|
|
|
wx.notifyBLECharacteristicValueChange({
|
|
deviceId: deviceId,
|
|
serviceId: _chars.status.serviceId,
|
|
characteristicId: _chars.status.uuid,
|
|
state: true,
|
|
success: function () {
|
|
wx.onBLECharacteristicValueChange(function (res) {
|
|
var frame = parseFrame(res.value)
|
|
if (frame) handleNotification(frame)
|
|
})
|
|
resolve()
|
|
},
|
|
fail: function () { resolve() }
|
|
})
|
|
})
|
|
}
|
|
|
|
function writeCommand(type, payload) {
|
|
return new Promise(function (resolve, reject) {
|
|
if (!_connected || !_deviceId) {
|
|
reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
|
|
return
|
|
}
|
|
if (!_chars.command) {
|
|
reject({ error_code: 0xFF, error_msg: 'command characteristic not found' })
|
|
return
|
|
}
|
|
|
|
var seq = nextSeq()
|
|
var payloadWithSeq = (payload || []).concat([seq])
|
|
var buffer = buildFrame(type, payloadWithSeq)
|
|
|
|
_pendingAcks[seq] = { resolve: resolve, reject: reject }
|
|
|
|
setTimeout(function () {
|
|
if (_pendingAcks[seq]) {
|
|
_pendingAcks[seq].reject({ error_code: 0xFF, error_msg: 'ACK timeout' })
|
|
delete _pendingAcks[seq]
|
|
}
|
|
}, 5000)
|
|
|
|
wx.writeBLECharacteristicValue({
|
|
deviceId: _deviceId,
|
|
serviceId: _chars.command.serviceId,
|
|
characteristicId: _chars.command.uuid,
|
|
value: buffer,
|
|
success: function () {},
|
|
fail: function () {
|
|
delete _pendingAcks[seq]
|
|
reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' })
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
function readDeviceInfo() {
|
|
return new Promise(function (resolve, reject) {
|
|
if (!_connected || !_deviceId || !_chars.deviceInfo) {
|
|
reject({ msg: '设备未连接或特征值未就绪' })
|
|
return
|
|
}
|
|
wx.readBLECharacteristicValue({
|
|
deviceId: _deviceId,
|
|
serviceId: _chars.deviceInfo.serviceId,
|
|
characteristicId: _chars.deviceInfo.uuid,
|
|
success: function () {},
|
|
fail: function () { reject({ msg: '读取设备信息失败' }) }
|
|
})
|
|
|
|
var handler = function (res) {
|
|
if (res.characteristicId.toUpperCase().indexOf(CHAR.DEVICE_INFO) !== -1) {
|
|
wx.offBLECharacteristicValueChange(handler)
|
|
var bytes = bufferToBytes(res.value)
|
|
if (bytes.length >= 14) {
|
|
resolve({
|
|
hw_version: (bytes[0] << 8) | bytes[1],
|
|
fw_version: (bytes[2] << 8) | bytes[3],
|
|
device_type: (bytes[4] << 8) | bytes[5],
|
|
device_id: bytesToHex(bytes.slice(6, 14))
|
|
})
|
|
} else {
|
|
reject({ msg: '设备信息格式错误' })
|
|
}
|
|
}
|
|
}
|
|
wx.onBLECharacteristicValueChange(handler)
|
|
})
|
|
}
|
|
|
|
function setParams(options) {
|
|
var regionMask = options.region_mask || REGION.FULL_FACE
|
|
var wavelength = options.wavelength || WAVELENGTH.R
|
|
var brightness = options.brightness || 200
|
|
var durationMs = options.duration_ms || 600000
|
|
var mode = options.mode !== undefined ? options.mode : TREAT_MODE.NORMAL
|
|
|
|
var payload = [
|
|
regionMask,
|
|
wavelength,
|
|
brightness,
|
|
uint32ToBytes(durationMs),
|
|
mode
|
|
].reduce(function (a, b) {
|
|
return a.concat(Array.isArray(b) ? b : [b])
|
|
}, [])
|
|
|
|
return writeCommand(CMD.SET_PARAMS, payload)
|
|
}
|
|
|
|
function startTreatment(regionMask) {
|
|
var mask = regionMask || REGION.FULL_FACE
|
|
return writeCommand(CMD.START, [mask])
|
|
}
|
|
|
|
function stopTreatment() {
|
|
return writeCommand(CMD.STOP, [])
|
|
}
|
|
|
|
function queryStatus() {
|
|
return writeCommand(CMD.QUERY_STATUS, [])
|
|
}
|
|
|
|
function bindDevice(userId, bindToken) {
|
|
var userBytes = hexToBytes(userId)
|
|
var tokenBytes = hexToBytes(bindToken)
|
|
var ts = Math.floor(Date.now() / 1000)
|
|
var tsBytes = uint32ToBytes(ts)
|
|
|
|
var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes)
|
|
return writeCommand(CMD.BIND, payload)
|
|
}
|
|
|
|
function unbindDevice(userId) {
|
|
var userBytes = hexToBytes(userId)
|
|
var payload = [0x02].concat(userBytes)
|
|
return writeCommand(CMD.UNBIND, payload)
|
|
}
|
|
|
|
function stopScan() {
|
|
wx.stopBluetoothDevicesDiscovery({})
|
|
wx.offBluetoothDeviceFound()
|
|
}
|
|
|
|
function disconnect() {
|
|
if (_deviceId) {
|
|
wx.closeBLEConnection({ deviceId: _deviceId })
|
|
_deviceId = null
|
|
}
|
|
_connected = false
|
|
_chars = {}
|
|
_pendingAcks = {}
|
|
_listeners = {}
|
|
wx.closeBluetoothAdapter({})
|
|
}
|
|
|
|
function getRegionName(mask) {
|
|
var names = []
|
|
var bits = [
|
|
[0x01, '左脸颊'], [0x02, '右脸颊'], [0x04, '额头'],
|
|
[0x08, '下巴'], [0x10, '鼻部'], [0x20, '左眼周'], [0x40, '右眼周']
|
|
]
|
|
for (var i = 0; i < bits.length; i++) {
|
|
if (mask & bits[i][0]) names.push(bits[i][1])
|
|
}
|
|
return names
|
|
}
|
|
|
|
function getWavelengthName(code) {
|
|
var map = { 1: '红外 850nm', 2: '红光 630nm', 3: '紫光 405nm', 4: '黄光 590nm' }
|
|
return map[code] || '未知'
|
|
}
|
|
|
|
function getModeStateName(code) {
|
|
var map = {
|
|
0x00: '空闲', 0x01: '扫描中', 0x02: '护理中',
|
|
0x03: '已暂停', 0x04: '已完成', 0x05: '异常', 0x06: 'OTA升级中'
|
|
}
|
|
return map[code] || '未知'
|
|
}
|
|
|
|
module.exports = {
|
|
SERVICE: SERVICE,
|
|
CHAR: CHAR,
|
|
CMD: CMD,
|
|
NOTIFY: NOTIFY,
|
|
MODE_STATE: MODE_STATE,
|
|
WAVELENGTH: WAVELENGTH,
|
|
TREAT_MODE: TREAT_MODE,
|
|
REGION: REGION,
|
|
DEVICE_ERR: DEVICE_ERR,
|
|
|
|
isConnected: isConnected,
|
|
getDeviceId: getDeviceId,
|
|
startScan: startScan,
|
|
stopScan: stopScan,
|
|
connect: connect,
|
|
disconnect: disconnect,
|
|
on: on,
|
|
off: off,
|
|
|
|
readDeviceInfo: readDeviceInfo,
|
|
setParams: setParams,
|
|
startTreatment: startTreatment,
|
|
stopTreatment: stopTreatment,
|
|
queryStatus: queryStatus,
|
|
bindDevice: bindDevice,
|
|
unbindDevice: unbindDevice,
|
|
|
|
buildFrame: buildFrame,
|
|
parseFrame: parseFrame,
|
|
parseStatusReport: parseStatusReport,
|
|
parseAck: parseAck,
|
|
parseTreatmentComplete: parseTreatmentComplete,
|
|
parseException: parseException,
|
|
|
|
getRegionName: getRegionName,
|
|
getWavelengthName: getWavelengthName,
|
|
getModeStateName: getModeStateName,
|
|
|
|
bufferToBytes: bufferToBytes,
|
|
bytesToBuffer: bytesToBuffer,
|
|
hexToBytes: hexToBytes,
|
|
bytesToHex: bytesToHex
|
|
}
|