CO2 meter extension - a sample for using WebHID on extension service worker. (#921)

* Initial README.md file

* fix design doc link

* Update README.md adding scheib

* Update README.md

* test

* update README.md with alvinjiooo

* add a ascii fish in README.md

* Update README.md

* skeloton extension

* Add original co2meter.html

* add buttons to settings page

* Initial storage module stub.

* Add storage module reference from settings page.

* Fix settings JS to be module compatible.

* Add stubs for settings storage writing

* Call saveCO2Value() from onInputReport in co2meter.html

* background page to read CO2 periodically based on the stored interval

* verion 1.0 /module/co2_meter.js

* Refactor settings.html and hook up CO2 driver

* update v2 of /module/co2_meter.js which access all methods from CO2Meter class

* Fix co2_meter import in background.js

* background page to show CO2 meter disconnected icon

* v2.1 update /module/co2_meter.js add support for virtual meter and connection listener also rquestPermission

* v2.1 update /module/co2_meter.js

* Import idb-keyval indexedDB library into storage.js, with trivial usage.

* background page handle disconnect connect device

* update v2.2 /module/co2_meter.js make  non-static and add guarding flag for exccessive reading

* update v2.3 /module/co2_meter.js await device to close before open device in

* update v2.4 /module/co2_meter.js change to return reject promise instead of Error in

* Add third-party/idb-keyval, use to set/get interval.

* Simplify settings and storage.

* handle background page device disconnect/connect

* Rename *_script.js files to only *.js.

* Add temp reading

* Add chart to popup.html with chartjs etc.

* Update extension tool tip to show connected/disconnected status.

* use indexedDB to store CO2 reading

* Renaming items in background.js

Name methods more clearly for what they are doing.
Collapse some code when possible to be inline.

* add  in Storage

* add support for storing temperature and query temperature in range

* background page boardcasting reading updated

* Refactor chart out of popup into chart.html for iframing

* Continued: chart out of popup into chart.html for iframing

* Use message channel for message broadcasting and move common constant to constant.js

* Work in progress for data into chart.

* Use set for clients in background page

* Add promise to storage constructor.

* Change IDB version number due to schema change

* Populate chart data.

* check store name before creating when version changed

* increase popup window and iframe size

* Update chart upon new data.

* Add calibration period and refactor inputReport

* Use flexbox to layout pages.

* move internal and temperature unit into setting store

* move dbInitialized into transaction oncomplete event

* remove reading out interval and temp unit log

* Remove co2meter.html

* Add .map files for third party code to remove devtools warnings.

* Display chart in Fahrenheit

* remove idb-keyvalue

* prettier storage.js and add comments to public methods

* Chart style: time axis respects time of data, no data points

* Show dialog on the chart when device is disconnected

* Add Example Data Button

* Close dialog button

* Implement toggling between Celsius / Fahrenheit

* polish co2_meter.js and remove virtual device and change default interval to 30 sec

* Change small icon to CO2 text

* change reading interval default to 30 secs which helps the chart update faster for fresh load case

* Fix lint error

* Clean up console.log

* Add chrome.alarm example

* remove keep alive for reading alarm

* refactor to keep connection open

* add README.md

* strip down

* addressed comments

* add link to README.md

---------

Co-authored-by: Vincent Scheib <scheib@users.noreply.github.com>
Co-authored-by: Alvin Ji <111466895+alvinjiooo@users.noreply.github.com>
Co-authored-by: Alvin Ji <alvinji@chromium.org>
Co-authored-by: Alpaca Jam <alvinji@google.com>
This commit is contained in:
Jack Hsieh 2023-06-16 11:56:03 -07:00 коммит произвёл GitHub
Родитель 73b7a456ac
Коммит e7c398bf93
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 573 добавлений и 0 удалений

Просмотреть файл

@ -0,0 +1,29 @@
# **Sample CO₂ Meter Chrome Extension**
The extension uses [WebHID](https://developer.chrome.com/en/articles/hid/) to access a device for measuring the CO₂ level and temperature in your surroundings.
## **Testing the extension**
1. Follow the instructions to load an [unpacked extension](https://developer.chrome.com/docs/extensions/mv3/getstarted/development-basics/#load-unpacked).
2. Connect the CO₂ meter (currently it only supports the [CO2Mini Indoor Air Quality Monitor](https://www.co2meter.com/products/co2mini-co2-indoor-air-quality-monitor) from CO2Meter.com).
3. Open the extension popup window and click “Settings” button to go to the settings page.
4. Click the “Grant CO2 meter permission” button and grant the permission to the CO₂ meter.
Following the above steps, the device connection session to the CO₂ meter will be created when the extension is running. The input reports from the device will be processed and are visble from the popup window or settings page.
## **Design**
- [co2_meter.js](modules/co2_meter.js): A CO2 meter device driver layer that uses WebHID to communicate with the device.
- [co2-state-iframe.js](./co2-state-iframe.js): A module to be embedded in a regular page or popup window for showing the current CO2 meter status. It listens for events from the extension service worker, such as meter readings or availability, and renders the results.
- [popup.js](./popup.js): For the extension popup window. It includes [co2-state-iframe.js](./co2-state-iframe.js) and a link to open [main-page.js](./main-page.js).
- [main-page.js](./main-page.js): The settings page for opening a popup to grant permission to the device. It includes [co2-state-iframe.js](./co2-state-iframe.js) as well.
- [background.js](./background.js): The script that runs on the extension service worker. This is the central piece of this extension, and it will:
- Initialize the CO2 meter for starting to generate reading input reports using [co2_meter.js](modules/co2_meter.js).
- Broadcast events (e.g., CO2 readings, CO2 availability) to registered clients (e.g., the popup window).
## **WebHID limitations in extension service workers**
WebHID will be officially available to extension service workers in Chrome 115. Before M115, it can be enabled through the flag chrome://flags#enable-web-hid-on-extension-service-worker. However, there are limitations to the support for WebHID in extension service workers:
- Before M115 with flag enabled, if the service worker is idle for longer than [30 seconds](https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/mojom/service_worker/service_worker.mojom;l=150;drc=ff468ef351dc107e9bb92635914e3908d763cf29) it may be terminated, closing the device connection session. This limitation will be resolved in M115.
- Device connection events are not fired if the device is plugged or unplugged while the service worker is inactive. We have [crbug.com/1446487](http://crbug.com/1446487) to track the resolution of this limitation. If your extension encounters issues because of this limitation, please leave a comment in the bug about your use case and how the limitation affects your extension.

Просмотреть файл

@ -0,0 +1,116 @@
// Copyright 2023 Google LLC
// 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.
'use strict';
import icon from './modules/icon.js';
import CO2Meter from './modules/co2_meter.js';
import {
PERMISSION_GRANTED_MESSAGE,
CO2_METER_UNAVAILABLE,
CO2_METER_AVAILABLE,
NO_CO2_METER_FOR_READING,
NEW_CO2_READING,
NEW_TEMP_READING,
READING_UNKNOWN
} from './modules/constant.js';
let clients = new Set();
let last_co2_reading = READING_UNKNOWN;
let last_temp_reading = READING_UNKNOWN;
async function co2MeterConnected() {
broadcastMessage(CO2_METER_AVAILABLE);
icon.setConnected();
startCO2Reading();
}
async function co2MeterDisconnected() {
CO2Meter.stopReading();
broadcastMessage(CO2_METER_UNAVAILABLE);
icon.setDisconnected();
last_co2_reading = READING_UNKNOWN;
last_temp_reading = READING_UNKNOWN;
await broadcastMessage(NEW_CO2_READING, last_co2_reading);
await broadcastMessage(NEW_TEMP_READING, last_temp_reading);
}
async function broadcastMessage(type, data) {
for (const client of clients.values()) {
client.postMessage({
type: type,
data: data
});
}
}
function onPermissionGranted() {
co2MeterConnected();
}
async function startCO2Reading() {
try {
await CO2Meter.startReading();
} catch (e) {
console.log('Exception when startCO2Reading:', e);
if (e === NO_CO2_METER_FOR_READING) {
co2MeterDisconnected();
}
}
}
async function OnCO2Reading(co2_reading) {
last_co2_reading = co2_reading;
await broadcastMessage(NEW_CO2_READING, co2_reading);
}
async function OnTempReading(temp_reading) {
last_temp_reading = temp_reading;
await broadcastMessage(NEW_TEMP_READING, temp_reading);
}
async function initialize() {
chrome.runtime.onMessage.addListener((message) => {
if (message === PERMISSION_GRANTED_MESSAGE) {
onPermissionGranted();
broadcastMessage(CO2_METER_AVAILABLE);
}
});
chrome.runtime.onConnect.addListener(async function (port) {
port.onDisconnect.addListener(function (port) {
clients.delete(port);
});
clients.add(port);
await broadcastMessage(NEW_CO2_READING, last_co2_reading);
await broadcastMessage(NEW_TEMP_READING, last_temp_reading);
});
await CO2Meter.init(
co2MeterConnected,
co2MeterDisconnected,
OnCO2Reading,
OnTempReading
);
startCO2Reading();
}
if (navigator.hid) {
initialize();
} else {
console.error(
'WebHID is not available! Use chrome://flags#enable-web-hid-on-extension-service-worker'
);
}

Просмотреть файл

@ -0,0 +1,22 @@
<html>
<script type="module" src="co2-state-iframe.js"></script>
<body>
<dl>
<dt><b>CO2 meter</b></dt>
<dd id="co2_meter_connected_status">unknown</dd>
<dt><b>CO2 reading (&#13273;)</b></dt>
<dd id="co2_reading">unknown</dd>
<dt><b>Temperature reading (&#8457;)</b></dt>
<dd id="temp_reading">unknown</dd>
</dl>
</body>
</html>
<dialog id="noDeviceDialog">
<p>
Device is not detected, please make sure the CO2 meter is connected and the
permission is granted!
</p>
<p>Permission can be granted in the settings page.</p>
<button id="closeDialogButton">Close</button>
</dialog>

Просмотреть файл

@ -0,0 +1,94 @@
// Copyright 2023 Google LLC
// 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.
import {
CO2_METER_UNAVAILABLE,
CO2_METER_AVAILABLE,
NEW_CO2_READING,
NEW_TEMP_READING,
READING_UNKNOWN
} from './modules/constant.js';
import CO2Meter from './modules/co2_meter.js';
window.onload = async () => {
// Register for messages to update chart upon new data readings.
chrome.runtime.connect().onMessage.addListener((msg) => {
switch (msg.type) {
case NEW_CO2_READING:
updateCO2Reading(msg.data);
break;
case NEW_TEMP_READING:
updateTempReading(msg.data);
break;
case CO2_METER_AVAILABLE:
updateCO2MeterStatus(true);
break;
case CO2_METER_UNAVAILABLE:
updateCO2MeterStatus(false);
break;
}
});
// Dialog
document.getElementById('closeDialogButton').onclick = () => {
document.getElementById('noDeviceDialog').close();
};
await CO2Meter.init(CO2MeterConnected, CO2MeterDisconnected);
const deviceStatus = await CO2Meter.getDeviceStatus();
updateCO2MeterStatus(deviceStatus);
};
function updateCO2Reading(co2_reading) {
let co2_reading_element = document.getElementById('co2_reading');
if (co2_reading === READING_UNKNOWN) {
co2_reading_element.textContent = 'unknown';
} else {
co2_reading_element.textContent = `${co2_reading} \u33d9`;
}
}
function updateTempReading(temp_reading) {
let temp_reading_element = document.getElementById('temp_reading');
if (temp_reading === READING_UNKNOWN) {
temp_reading_element.textContent = 'unknown';
} else {
const fahrenheit = CO2Meter.tempReadingToFahrenheit(temp_reading);
temp_reading_element.textContent = `${fahrenheit}\u2109`;
}
}
function updateCO2MeterStatus(connected) {
let noDeviceDialog = document.getElementById('noDeviceDialog');
let co2_meter_connected_status = document.getElementById(
'co2_meter_connected_status'
);
if (connected) {
noDeviceDialog.close();
co2_meter_connected_status.textContent = 'connected';
} else {
if (!noDeviceDialog.open) {
noDeviceDialog.showModal();
}
co2_meter_connected_status.textContent = 'disconnected';
}
}
function CO2MeterConnected() {
updateCO2MeterStatus(true);
}
function CO2MeterDisconnected() {
updateCO2MeterStatus(false);
}

Двоичные данные
functional-samples/sample.co2meter/images/icon128.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 17 KiB

Двоичные данные
functional-samples/sample.co2meter/images/icon32.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 402 B

Двоичные данные
functional-samples/sample.co2meter/images/icon32.psd Normal file

Двоичный файл не отображается.

Двоичные данные
functional-samples/sample.co2meter/images/icon32_disconnected.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 482 B

Просмотреть файл

@ -0,0 +1,19 @@
<html>
<head>
<title>CO2 Meter</title>
<script type="module" src="main-page.js"></script>
<style>
iframe {
border: none;
}
</style>
</head>
<body>
<div>
<button type="button" id="grantPermissionButton">
Grant CO2 meter permission
</button>
</div>
<iframe src="co2-state-iframe.html"></iframe>
</body>
</html>

Просмотреть файл

@ -0,0 +1,24 @@
// Copyright 2023 Google LLC
// 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.
import CO2Meter from './modules/co2_meter.js';
window.onload = async () => {
// Permission
// Popup window can't open a permission prompt so we have to use a page instead.
// This issue is being tracked by crbug.com/1349183.
document.getElementById('grantPermissionButton').onclick = () => {
CO2Meter.requestPermission();
};
};

Просмотреть файл

@ -0,0 +1,21 @@
{
"name": "CO2 meter extension",
"description": "An extension that connects to a CO2 meter using WebHID extension service workers",
"version": "1.0",
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_icon": {
"32": "images/icon32.png"
},
"default_title": "Show CO2 meter status",
"default_popup": "popup.html"
},
"icons": {
"32": "images/icon32.png",
"128": "images/icon128.png"
}
}

Просмотреть файл

@ -0,0 +1,163 @@
// Copyright 2023 Google LLC
//
// 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.
/**
* @filename co2_meter.js
*
* @description CO2Meter provides methods for accessing status and data of a
* CO2 meter. When creating a CO2Meter, it has to await `init()` to finish
* before quering device status.
*/
import { PERMISSION_GRANTED_MESSAGE } from './constant.js';
const key = new Uint8Array([0xc4, 0xc6, 0xc0, 0x92, 0x40, 0x23, 0xdc, 0x96]);
function KelvinToFahrenheit(k) {
return Math.trunc(((k - 273.15) * 9) / 5 + 32);
}
class CO2Meter {
constructor() {
this.device = null;
this.connectClientCB = null;
this.disconnectClientCB = null;
this.co2ReadingClientCB = null;
this.tempReadingClientCB = null;
this.connectHandler = this.connectHandler.bind(this);
this.disconnectHandler = this.disconnectHandler.bind(this);
this.onInputReport = this.onInputReport.bind(this);
}
/**
* @description This function initializes the CO2Meter object.
*/
async init(
connectCallback = null,
disconnectCallback = null,
co2ReadingCallback = null,
tempReadingCallback = null
) {
this.connectClientCB = connectCallback;
this.disconnectClientCB = disconnectCallback;
this.co2ReadingClientCB = co2ReadingCallback;
this.tempReadingClientCB = tempReadingCallback;
navigator.hid.addEventListener('connect', this.connectHandler);
navigator.hid.addEventListener('disconnect', this.disconnectHandler);
console.log('CO2Meter init() done');
}
async startReading() {
if (this.device) {
console.log('CO2 reading has already started!');
return;
}
const devices = await navigator.hid.getDevices();
if (devices.length == 0) {
throw 'No CO2 meter for reading!';
}
this.device = devices[0];
try {
await this.device.open();
await this.device.sendFeatureReport(0, key);
} catch (e) {
console.log('CO2 reading exception:', e);
await this.device.close();
this.device = null;
throw 'Fail to open CO2 meter for reading!';
}
this.device.addEventListener('inputreport', this.onInputReport);
}
async stopReading() {
if (this.device) {
this.device.removeEventListener('inputreport', this.onInputReport);
await this.device.close();
this.device = null;
}
}
onInputReport(report) {
let data = new Uint8Array(
report.data.buffer,
report.data.byteOffset,
report.data.byteLength
);
const op = data[0];
let val = (data[1] << 8) | data[2];
if (op == 0x50) {
console.log(`Current CO2 reading is ${val}`);
if (this.co2ReadingClientCB) {
this.co2ReadingClientCB(val);
}
} else if (op == 0x42) {
val = val / 16;
console.log(`Current Temp reading is ${val}`);
if (this.tempReadingClientCB) {
this.tempReadingClientCB(val);
}
}
}
/**
* @description Request user to grant permission for using CO2 meter.
* The extension currently only support this model:
* https://www.co2meter.com/products/co2mini-co2-indoor-air-quality-monitor
*/
async requestPermission() {
const devices = await navigator.hid.requestDevice({
filters: [{ vendorId: 1241, productId: 41042 }]
});
console.log('CO2 meter permission granted!', devices[0]);
chrome.runtime.sendMessage(PERMISSION_GRANTED_MESSAGE);
}
connectHandler() {
if (this.connectClientCB && typeof this.connectClientCB === 'function') {
this.connectClientCB();
}
}
disconnectHandler() {
if (this.device) {
this.device.close();
}
this.device = null;
if (
this.disconnectClientCB &&
typeof this.disconnectClientCB === 'function'
) {
this.disconnectClientCB();
}
}
/**
* @description Get Device connected status.
* @return {Boolean}
*/
async getDeviceStatus() {
const devices = await navigator.hid.getDevices();
return devices.length > 0;
}
tempReadingToFahrenheit(temp_reading) {
return KelvinToFahrenheit(temp_reading);
}
}
export default new CO2Meter();

Просмотреть файл

@ -0,0 +1,21 @@
// Copyright 2023 Google LLC
//
// 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.
export const PERMISSION_GRANTED_MESSAGE = 'permission granted';
export const CO2_METER_UNAVAILABLE = 'co2 meter unavailable';
export const CO2_METER_AVAILABLE = 'co2 meter available';
export const NO_CO2_METER_FOR_READING = 'No CO2 meter for reading!';
export const NEW_CO2_READING = 'new co2 reading';
export const NEW_TEMP_READING = 'new temperature reading';
export const READING_UNKNOWN = 'reading unknown';

Просмотреть файл

@ -0,0 +1,29 @@
// Copyright 2023 Google LLC
//
// 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.
class Icon {
constructor() {}
setConnected() {
chrome.action.setTitle({ title: 'Connected' });
chrome.action.setIcon({ path: { 32: 'images/icon32.png' } });
}
setDisconnected() {
chrome.action.setTitle({ title: 'Disconnected' });
chrome.action.setIcon({ path: { 32: 'images/icon32_disconnected.png' } });
}
}
export default new Icon();

Просмотреть файл

@ -0,0 +1,14 @@
<html>
<script type="module" src="popup.js"></script>
<style>
iframe {
border: none;
}
</style>
<body>
<div>
<button type="button" id="mainPageButton">Settings</button>
</div>
<iframe src="co2-state-iframe.html"></iframe>
</body>
</html>

Просмотреть файл

@ -0,0 +1,21 @@
// Copyright 2023 Google LLC
// 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.
'use strict';
window.onload = async () => {
document.getElementById('mainPageButton').onclick = () => {
window.open('main-page.html', '_blank');
};
};