Skip to content

Commit

Permalink
setting up websocket side
Browse files Browse the repository at this point in the history
  • Loading branch information
pmalacho-mit committed Jan 5, 2024
1 parent e70e63d commit 23e8c57
Show file tree
Hide file tree
Showing 3 changed files with 235 additions and 320 deletions.
262 changes: 193 additions & 69 deletions extensions/src/doodlebot/Doodlebot.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,7 @@
/*
* Doodlebot Web Bluetooth
* Built on top of
* - micro:bit Web Bluetooth
* - Copyright (c) 2019 Rob Moran
*
* The MIT License (MIT)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

import EventEmitter from "events";
import { Service } from "./communication/ServiceHelper";
import UartService, { UartEvents } from "./communication/UartService";
import { Command, ReceivedCommand, Sensor, SensorKey, command, keyBySensor, motorCommandReceived, sensor } from "./enums";
import UartService from "./communication/UartService";
import { Command, NetworkStatus, ReceivedCommand, SensorKey, command, keyBySensor, motorCommandReceived, networkStatus, port, sensor } from "./enums";

export type Services = Awaited<ReturnType<typeof Doodlebot.getServices>>;
export type MotorStepRequest = { steps: number, stepsPerSecond: number };
Expand All @@ -37,18 +10,44 @@ export type Vector3D = { x: number, y: number, z: number };
export type Color = { red: number, green: number, blue: number, alpha: number };
export type SensorReading = number | Vector3D | Bumper | Color;
export type SensorData = Doodlebot["sensorData"];
export type NetworkCredentials = { ssid: string, password: string };
export type NetworkConnection = { ip: string, hostname: string };

type Pending = Record<"motor" | "wifi" | "websocket", Promise<any> | undefined>;

type SubscriptionTarget = Pick<EventTarget, "addEventListener" | "removeEventListener">;

type Subscription<T extends SubscriptionTarget> = {
target: T,
event: Parameters<T["addEventListener"]>[0],
listener: Parameters<T["addEventListener"]>[1],
}

type MotorCommand = "steps" | "arc" | "stop";
type DisplayCommand = "clear";

const trimNewtworkStatusMessage = (message: string, prefix: NetworkStatus) => message.replace(prefix, "").trim();

const localIp = "127.0.0.1";

const events = {
stop: "motor",
connect: "connect",
disconnect: "disconnect",
} as const;

export default class Doodlebot {
static async createService<T extends Service & (new (...args: any) => any)>(
/**
*
* @param services
* @param serviceClass
* @returns
*/
static async tryCreateService<T extends Service & (new (...args: any) => any)>(
services: BluetoothRemoteGATTService[], serviceClass: T
): Promise<InstanceType<T>> {
const found = services.find(
(service) => service.uuid === serviceClass.uuid
);

if (!found) return undefined;

return await serviceClass.create(found);
const found = services.find((service) => service.uuid === serviceClass.uuid);
return found ? await serviceClass.create(found) : undefined;
}

/**
Expand All @@ -57,9 +56,10 @@ export default class Doodlebot {
* @param devicePrefix @todo unused
* @returns
*/
static async requestRobot(bluetooth: Bluetooth, devicePrefix: string) {
static async requestRobot(bluetooth: Bluetooth, ...filters: BluetoothLEScanFilter[]) {
const device = await bluetooth.requestDevice({
filters: [
...(filters ?? []),
{
services: [UartService.uuid]
},
Expand All @@ -69,27 +69,43 @@ export default class Doodlebot {
return device;
}

/**
* Get
* @param device
* @returns
*/
static async getServices(device: BluetoothDevice) {
if (!device || !device.gatt) return null;
if (!device.gatt.connected) await device.gatt.connect();

const services = await device.gatt.getPrimaryServices();
const uartService = await Doodlebot.createService(services, UartService);
const uartService = await Doodlebot.tryCreateService(services, UartService);

return { uartService, };
}

static async create(ble: Bluetooth, deviceNamePrefix: string) {
const robot = await Doodlebot.requestRobot(ble, deviceNamePrefix);
/**
*
* @param ble
* @param filters
* @throws
* @returns
*/
static async tryCreate(ssid: string, password: string, ble: Bluetooth, ...filters: BluetoothLEScanFilter[]) {
const robot = await Doodlebot.requestRobot(ble, ...filters);
const services = await Doodlebot.getServices(robot);
if (!services) throw new Error("Unable to connect to doodlebot's UART service");
return new Doodlebot(robot, services);
return new Doodlebot(robot, services, ssid, password);
}

private pendingMotorCommand: Promise<any>;
private pending: Pending = { motor: undefined, wifi: undefined, websocket: undefined };
private onMotor = new EventEmitter();
private onSensor = new EventEmitter();
private listeners = new Map<keyof UartEvents, (...args: any[]) => void>();
private onNetwork = new EventEmitter();
private disconnectCallbacks = new Set<() => void>();
private subscriptions = new Array<Subscription<any>>();
private connection: NetworkConnection;
private websocket: WebSocket;

private sensorData = ({
bumper: { front: 0, back: 0 },
Expand Down Expand Up @@ -119,10 +135,15 @@ export default class Doodlebot {
light: false
};

constructor(private device: BluetoothDevice, private services: Services) {
const { listeners } = this;
listeners.set("receiveText", this.receiveText.bind(this));
for (const [key, listener] of listeners) services.uartService.addEventListener(key, listener);
constructor(private device: BluetoothDevice, private services: Services, private ssid: string, private wifiPassword: string) {
this.subscribe(services.uartService, "receiveText", this.receiveTextBLE.bind(this));
this.subscribe(device, "gattserverdisconnected", this.handleBleDisconnect.bind(this));
this.connectToWebsocket({ ssid, password: wifiPassword });
}

private subscribe<T extends SubscriptionTarget>(target: T, event: Subscription<T>["event"], listener: Subscription<T>["listener"]) {
target.addEventListener(event, listener);
this.subscriptions.push({ target, event, listener });
}

private formCommand(...args: (string | number)[]) {
Expand All @@ -142,20 +163,37 @@ export default class Doodlebot {
return uartService.sendText(this.formCommand(command, ...args));
}

private setSensor<T extends SensorKey>(type: T, value: SensorData[T]) {
private async sendWebsocketCommand(command: Command, ...args: (string | number)[]) {
await this.connectToWebsocket();
this.websocket.send(this.formCommand(command, ...args));
}

private updateSensor<T extends SensorKey>(type: T, value: SensorData[T]) {
this.onSensor.emit(type, value);
this.sensorData[type] = value;
}

private receiveText(event: CustomEvent<string>) {
private updateNetworkStatus(ipComponent: string, hostnameComponent: string) {
const ip = trimNewtworkStatusMessage(ipComponent, networkStatus.ipPrefix);
const hostname = trimNewtworkStatusMessage(hostnameComponent, networkStatus.hostnamePrefix);
if (ip === localIp) return this.onNetwork.emit(events.disconnect);
this.connection = { ip, hostname };
this.onNetwork.emit(events.connect, this.connection);
}

private receiveTextBLE(event: CustomEvent<string>) {
for (const { command, parameters } of this.parseCommand(event.detail)) {
if (command.startsWith(networkStatus.ipPrefix)) {
this.updateNetworkStatus(command, parameters[0]);
continue;
}
switch (command) {
case motorCommandReceived:
this.onMotor.emit("stop");
this.onMotor.emit(events.stop);
break;
case sensor.bumper: {
const [front, back] = parameters.map((parameter) => Number.parseFloat(parameter));
this.setSensor(keyBySensor[command], { front, back });
this.updateSensor(keyBySensor[command], { front, back });
break;
}
case sensor.distance:
Expand All @@ -165,19 +203,19 @@ export default class Doodlebot {
case sensor.temperature:
case sensor.pressure: {
const value = Number.parseFloat(parameters[0]);
this.setSensor(keyBySensor[command], value);
this.updateSensor(keyBySensor[command], value);
break;
}
case sensor.gyroscope:
case sensor.magnometer:
case sensor.accelerometer: {
const [x, y, z] = parameters.map((parameter) => Number.parseFloat(parameter));
this.setSensor(keyBySensor[command], { x, y, z });
this.updateSensor(keyBySensor[command], { x, y, z });
break;
}
case sensor.light: {
const [red, green, blue, alpha] = parameters.map((parameter) => Number.parseFloat(parameter));
this.setSensor(keyBySensor[command], { red, green, blue, alpha });
this.updateSensor(keyBySensor[command], { red, green, blue, alpha });
break;
}
default:
Expand All @@ -186,6 +224,26 @@ export default class Doodlebot {
}
}

private onWebsocketMessage(event: MessageEvent) {
}

private invalidateWifiConnection() {
this.connection = undefined;
this.pending.wifi = undefined;
this.pending.websocket = undefined;
this.websocket?.close();
this.websocket = undefined;
}

private handleBleDisconnect() {
for (const callback of this.disconnectCallbacks) callback();
for (const { target, event, listener } of this.subscriptions) target.removeEventListener(event, listener);
}

onDisconnect(...callbacks: (() => void)[]) {
for (const callback of callbacks) this.disconnectCallbacks.add(callback);
}

/**
*
* @param type
Expand Down Expand Up @@ -236,28 +294,94 @@ export default class Doodlebot {
* @param type
*/
async motorCommand(type: "stop");
async motorCommand(type: string, ...args: any[]): Promise<boolean> {
async motorCommand(type: string, ...args: any[]) {
const { pending: { motor: pending } } = this;
switch (type) {
case "steps": {
if (this.pendingMotorCommand) await this.pendingMotorCommand;
if (pending) await pending;
const [left, right] = args as MotorStepRequest[];
await this.sendBLECommand(command.motor, left.steps, right.steps, left.stepsPerSecond, right.stepsPerSecond);
this.pendingMotorCommand = new Promise((resolve) => this.onMotor.once("stop", resolve));
await this.pendingMotorCommand;
return true;
return await this.untilFinishedPending("motor", new Promise(async (resolve) => {
await this.sendBLECommand(command.motor, left.steps, right.steps, left.stepsPerSecond, right.stepsPerSecond);
this.onMotor.once(events.stop, resolve);
}));
}
case "arc": {
if (this.pendingMotorCommand) await this.pendingMotorCommand;
if (pending) await pending;
const [radius, degrees] = args as number[];
await this.sendBLECommand(command.motor, radius, degrees);
this.pendingMotorCommand = new Promise((resolve) => this.onMotor.once("stop", resolve));
await this.pendingMotorCommand;
break;
return await this.untilFinishedPending("motor", new Promise(async (resolve) => {
await this.sendBLECommand(command.motor, radius, degrees);
this.onMotor.once(events.stop, resolve);
}));
}
case "stop":
await this.sendBLECommand(command.motor, "s");
this.pendingMotorCommand = new Promise((resolve) => this.onMotor.once("stop", resolve));
await this.pendingMotorCommand;
return await this.untilFinishedPending("motor", new Promise(async (resolve) => {
await this.sendBLECommand(command.motor, "s");
this.onMotor.once(events.stop, resolve);
}));
}
}

/**
*
*/
async lowPowerMode() {
await this.sendBLECommand(command.lowPower);
}

/**
*
* @param ssid
* @param password
*/
async connectToWifi(credentials?: NetworkCredentials) {
const { ssid, pending: { wifi: pending } } = this;
const invalidate = credentials && credentials.ssid !== ssid;
if (invalidate) {
this.invalidateWifiConnection();
this.ssid = credentials.ssid;
this.wifiPassword = credentials.password;
}
else if (pending) await pending;

if (this.connection) return;

await this.untilFinishedPending("wifi", new Promise(async (resolve) => {
await this.sendBLECommand(command.wifi, this.ssid, this.wifiPassword);
this.onNetwork.once(events.connect, resolve)
}));
}

async untilFinishedPending(type: keyof Pending, promise: Promise<any>) {
this.pending[type] = promise;
await promise;
this.pending[type] = undefined;
}

/**
*
* @param credentials
*/
async connectToWebsocket(credentials?: NetworkCredentials) {
await this.connectToWifi(credentials);
const { pending: { websocket: pending } } = this;
if (pending) await pending;
if (this.websocket) return;
this.websocket = new WebSocket(`ws://${this.connection.ip}:${port.websocket}`);
await this.untilFinishedPending("websocket", new Promise<void>((resolve) => {
const resolveAndRemove = () => {
this.websocket.removeEventListener("open", resolveAndRemove);
resolve();
}
this.websocket.addEventListener("open", resolveAndRemove);
this.websocket.addEventListener("message", this.onWebsocketMessage.bind(this));
}));
}

async display(type: "clear");
async display(type: DisplayCommand) {
switch (type) {
case "clear":
await this.sendWebsocketCommand(command.display, "c");
break;
}
}
Expand Down
Loading

0 comments on commit 23e8c57

Please sign in to comment.