| // Copyright 2017 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * Javascript for ValueControl, served from chrome://bluetooth-internals/. |
| */ |
| |
| import {assert, assertNotReached} from 'chrome://resources/js/assert.js'; |
| import {CustomElement} from 'chrome://resources/js/custom_element.js'; |
| |
| import {GattResult, Property} from './device.mojom-webui.js'; |
| import {connectToDevice} from './device_broker.js'; |
| import {showSnackbar, SnackbarType} from './snackbar.js'; |
| import {getTemplate} from './value_control.html.js'; |
| |
| /** |
| * @typedef {{ |
| * deviceAddress: string, |
| * serviceId: string, |
| * characteristicId: string, |
| * descriptorId: (string|undefined), |
| * properties: (number|undefined), |
| * }} |
| */ |
| let ValueLoadOptions; |
| |
| /** @enum {string} */ |
| export const ValueDataType = { |
| HEXADECIMAL: 'Hexadecimal', |
| UTF8: 'UTF-8', |
| DECIMAL: 'Decimal', |
| }; |
| |
| /** |
| * A container for an array value that needs to be converted to multiple |
| * display formats. Internally, the value is stored as an array and converted |
| * to the needed display type at runtime. |
| */ |
| export class Value { |
| /** @param {!Array<number>} initialValue */ |
| constructor(initialValue) { |
| /** @private {!Array<number>} */ |
| this.value_ = initialValue; |
| } |
| |
| /** |
| * Gets the backing array value. |
| * @return {!Array<number>} |
| */ |
| getArray() { |
| return this.value_; |
| } |
| |
| /** |
| * Sets the backing array value. |
| * @param {!Array<number>} newValue |
| */ |
| setArray(newValue) { |
| this.value_ = newValue; |
| } |
| |
| /** |
| * Sets the value by converting the |newValue| string using the formatting |
| * specified by |valueDataType|. |
| * @param {!ValueDataType} valueDataType |
| * @param {string} newValue |
| */ |
| setAs(valueDataType, newValue) { |
| switch (valueDataType) { |
| case ValueDataType.HEXADECIMAL: |
| this.setValueFromHex_(newValue); |
| break; |
| |
| case ValueDataType.UTF8: |
| this.setValueFromUTF8_(newValue); |
| break; |
| |
| case ValueDataType.DECIMAL: |
| this.setValueFromDecimal_(newValue); |
| break; |
| } |
| } |
| |
| /** |
| * Gets the value as a string representing the given |valueDataType|. |
| * @param {!ValueDataType} valueDataType |
| * @return {string} |
| */ |
| getAs(valueDataType) { |
| switch (valueDataType) { |
| case ValueDataType.HEXADECIMAL: |
| return this.toHex_(); |
| |
| case ValueDataType.UTF8: |
| return this.toUTF8_(); |
| |
| case ValueDataType.DECIMAL: |
| return this.toDecimal_(); |
| } |
| assertNotReached(); |
| return ''; |
| } |
| |
| /** |
| * Converts the value to a hex string. |
| * @return {string} |
| * @private |
| */ |
| toHex_() { |
| if (this.value_.length === 0) { |
| return ''; |
| } |
| |
| return this.value_.reduce(function(result, value, index) { |
| return result + ('0' + value.toString(16)).substr(-2); |
| }, '0x'); |
| } |
| |
| /** |
| * Sets the value from a hex string. |
| * @param {string} newValue |
| * @private |
| */ |
| setValueFromHex_(newValue) { |
| if (!newValue) { |
| this.value_ = []; |
| return; |
| } |
| |
| if (!newValue.startsWith('0x')) { |
| throw new Error('Expected new value to start with "0x".'); |
| } |
| |
| const result = []; |
| for (let i = 2; i < newValue.length; i += 2) { |
| result.push(parseInt(newValue.substr(i, 2), 16)); |
| } |
| |
| this.value_ = result; |
| } |
| |
| /** |
| * Converts the value to a UTF-8 encoded text string. |
| * @return {string} |
| * @private |
| */ |
| toUTF8_() { |
| return this.value_.reduce(function(result, value) { |
| return result + String.fromCharCode(value); |
| }, ''); |
| } |
| |
| /** |
| * Sets the value from a UTF-8 encoded text string. |
| * @param {string} newValue |
| * @private |
| */ |
| setValueFromUTF8_(newValue) { |
| if (!newValue) { |
| this.value_ = []; |
| return; |
| } |
| |
| this.value_ = Array.from(newValue).map(function(char) { |
| return char.charCodeAt(0); |
| }); |
| } |
| |
| /** |
| * Converts the value to a decimal string with numbers delimited by '-'. |
| * @return {string} |
| * @private |
| */ |
| toDecimal_() { |
| return this.value_.join('-'); |
| } |
| |
| /** |
| * Sets the value from a decimal string delimited by '-'. |
| * @param {string} newValue |
| * @private |
| */ |
| setValueFromDecimal_(newValue) { |
| if (!newValue) { |
| this.value_ = []; |
| return; |
| } |
| |
| if (!/^[0-9\-]*$/.test(newValue)) { |
| throw new Error('New value can only contain numbers and hyphens.'); |
| } |
| |
| this.value_ = newValue.split('-').map(function(val) { |
| return parseInt(val, 10); |
| }); |
| } |
| } |
| |
| /** |
| * A set of inputs that allow a user to request reads and writes of values. |
| * This control allows the value to be displayed in multiple forms |
| * as defined by the |ValueDataType| array. Values must be written |
| * in these formats. Read and write capability is controlled by a |
| * 'properties' bitfield provided by the characteristic. |
| * @constructor |
| * @extends {HTMLDivElement} |
| */ |
| export class ValueControlElement extends CustomElement { |
| static get is() { |
| return 'value-control'; |
| } |
| |
| static get template() { |
| return getTemplate(); |
| } |
| |
| static get observedAttributes() { |
| return ['data-value', 'data-options']; |
| } |
| |
| constructor() { |
| super(); |
| |
| /** @private {!Value} */ |
| this.value_ = new Value([]); |
| /** @private {?string} */ |
| this.deviceAddress_ = null; |
| /** @private {?string} */ |
| this.serviceId_ = null; |
| /** @private {?string} */ |
| this.characteristicId_ = null; |
| /** @private {?string|undefined} */ |
| this.descriptorId_ = null; |
| /** @private {number} */ |
| this.properties_ = Number.MAX_SAFE_INTEGER; |
| /** @private {!HTMLInputElement} */ |
| this.valueInput_ = this.shadowRoot.querySelector('input'); |
| /** @private {!HTMLSelectElement} */ |
| this.typeSelect_ = this.shadowRoot.querySelector('select'); |
| /** @private {!HTMLButtonElement} */ |
| this.writeBtn_ = this.shadowRoot.querySelector('button.write'); |
| /** @private {!HTMLButtonElement} */ |
| this.readBtn_ = this.shadowRoot.querySelector('button.read'); |
| /** @private {!HTMLElement} */ |
| this.unavailableMessage_ = this.shadowRoot.querySelector('h3'); |
| } |
| |
| connectedCallback() { |
| this.classList.add('value-control'); |
| |
| this.valueInput_.addEventListener('change', function() { |
| try { |
| this.value_.setAs(this.typeSelect_.value, this.valueInput_.value); |
| } catch (e) { |
| showSnackbar(e.message, SnackbarType.ERROR); |
| } |
| }.bind(this)); |
| |
| this.typeSelect_.addEventListener('change', this.redraw.bind(this)); |
| |
| this.readBtn_.addEventListener('click', this.readValue_.bind(this)); |
| |
| this.writeBtn_.addEventListener('click', this.writeValue_.bind(this)); |
| |
| this.redraw(); |
| } |
| |
| /** |
| * Sets the settings used by the value control and redraws the control to |
| * match the read/write settings in |options.properties|. If properties |
| * are not provided, no restrictions on reading/writing are applied. |
| */ |
| attributeChangedCallback(name, oldValue, newValue) { |
| assert(name === 'data-value' || name === 'data-options'); |
| |
| if (oldValue === newValue) { |
| return; |
| } |
| |
| if (name === 'data-options') { |
| const options = JSON.parse(newValue); |
| this.deviceAddress_ = options.deviceAddress; |
| this.serviceId_ = options.serviceId; |
| this.characteristicId_ = options.characteristicId; |
| this.descriptorId_ = options.descriptorId; |
| |
| if (options.properties) { |
| this.properties_ = options.properties; |
| } |
| } else { |
| this.value_.setArray(JSON.parse(newValue)); |
| } |
| |
| this.redraw(); |
| } |
| |
| /** |
| * Redraws the value control with updated layout depending on the |
| * availability of reads and writes and the current cached value. |
| */ |
| redraw() { |
| this.readBtn_.hidden = (this.properties_ & Property.READ) === 0; |
| this.writeBtn_.hidden = (this.properties_ & Property.WRITE) === 0 && |
| (this.properties_ & Property.WRITE_WITHOUT_RESPONSE) === 0; |
| |
| const isAvailable = !this.readBtn_.hidden || !this.writeBtn_.hidden; |
| this.unavailableMessage_.hidden = isAvailable; |
| this.valueInput_.hidden = !isAvailable; |
| this.typeSelect_.hidden = !isAvailable; |
| |
| if (!isAvailable) { |
| return; |
| } |
| |
| this.valueInput_.value = this.value_.getAs(this.typeSelect_.value); |
| } |
| |
| /** |
| * Sets the value of the control. |
| * @param {!Array<number>} value |
| */ |
| setValue(value) { |
| this.value_.setArray(value); |
| this.redraw(); |
| } |
| |
| /** |
| * Gets an error string describing the given |result| code. |
| * @param {!GattResult} result |
| * @private |
| */ |
| getErrorString_(result) { |
| // TODO(crbug.com/40492643): Replace with more descriptive error |
| // messages. |
| return Object.keys(GattResult).find(function(key) { |
| return GattResult[key] === result; |
| }); |
| } |
| |
| /** |
| * Called when the read button is pressed. Connects to the device and |
| * retrieves the current value of the characteristic in the |service_id| |
| * with id |characteristic_id|. If |descriptor_id| is defined, the |
| * descriptor value with |descriptor_id| is read instead. |
| * @private |
| */ |
| readValue_() { |
| this.readBtn_.disabled = true; |
| |
| assert(this.deviceAddress_); |
| connectToDevice(this.deviceAddress_) |
| .then(function(device) { |
| if (this.descriptorId_) { |
| return device.readValueForDescriptor( |
| this.serviceId_, this.characteristicId_, this.descriptorId_); |
| } |
| |
| return device.readValueForCharacteristic( |
| this.serviceId_, this.characteristicId_); |
| }.bind(this)) |
| .then(function(response) { |
| this.readBtn_.disabled = false; |
| |
| if (response.result === GattResult.SUCCESS) { |
| this.setValue(response.value); |
| showSnackbar( |
| this.deviceAddress_ + ': Read succeeded', SnackbarType.SUCCESS); |
| return; |
| } |
| |
| const errorString = this.getErrorString_(response.result); |
| showSnackbar( |
| this.deviceAddress_ + ': ' + errorString, SnackbarType.ERROR, |
| 'Retry', this.readValue_.bind(this)); |
| }.bind(this)); |
| } |
| |
| /** |
| * Called when the write button is pressed. Connects to the device and |
| * retrieves the current value of the characteristic in the |
| * |service_id| with id |characteristic_id|. If |descriptor_id| is defined, |
| * the descriptor value with |descriptor_id| is written instead. |
| * @private |
| */ |
| writeValue_() { |
| this.writeBtn_.disabled = true; |
| |
| assert(this.deviceAddress_); |
| connectToDevice(this.deviceAddress_) |
| .then(function(device) { |
| if (this.descriptorId_) { |
| return device.writeValueForDescriptor( |
| this.serviceId_, this.characteristicId_, this.descriptorId_, |
| this.value_.getArray()); |
| } |
| |
| return device.writeValueForCharacteristic( |
| this.serviceId_, this.characteristicId_, this.value_.getArray()); |
| }.bind(this)) |
| .then(function(response) { |
| this.writeBtn_.disabled = false; |
| |
| if (response.result === GattResult.SUCCESS) { |
| showSnackbar( |
| this.deviceAddress_ + ': Write succeeded', |
| SnackbarType.SUCCESS); |
| return; |
| } |
| |
| const errorString = this.getErrorString_(response.result); |
| showSnackbar( |
| this.deviceAddress_ + ': ' + errorString, SnackbarType.ERROR, |
| 'Retry', this.writeValue_.bind(this)); |
| }.bind(this)); |
| } |
| } |
| |
| customElements.define('value-control', ValueControlElement); |