diff --git a/hub-scripts/_builtin_port_view.py b/hub-scripts/_builtin_port_view.py new file mode 100644 index 000000000..a1221f19a --- /dev/null +++ b/hub-scripts/_builtin_port_view.py @@ -0,0 +1,327 @@ +from pybricks.pupdevices import ( + DCMotor, + Motor, + ColorSensor, + UltrasonicSensor, + ForceSensor, + ColorDistanceSensor, + TiltSensor, + InfraredSensor, +) +from pybricks.parameters import Port, Stop +from pybricks.tools import wait, AppData +try: + from pybricks.iodevices import PUPDevice +except: + pass + +# Figure out the available ports for the given hub. +ports = [Port.A, Port.B] +try: + ports += [Port.C, Port.D] + ports += [Port.E, Port.F] +except AttributeError: + pass +port_modes = [0 for _ in range(len(ports))] +port_commands = [[] for _ in range(len(ports))] + +from pybricks.hubs import ThisHub +hub = ThisHub() +try: + from pybricks.hubs import PrimeHub + from pybricks.parameters import Icon, Button + + hub = PrimeHub() + hub.light.off() + + # Create an animation of the heart icon with changing brightness. + brightness = list(range(0, 70, 4)) + list(range(70, 0, -4)) + hub.display.animate([Icon.HEART * i / 100 for i in brightness], 120) + # while hub.buttons.pressed(): + # wait(10) + hub.system.set_stop_button([Button.LEFT, Button.RIGHT]) +except ImportError: + pass + + +# Allocates small buffer so the IDE can send us commands, +# mode index values for each sensor. +# message format version:1, packet_counter:1, message_type:1, payload:3-max +# execute_action "a" # 'a' + action_name:1 +# shutdown "as" +# port_operations "p" # port_index:1, operation:1, values:1 +# set_port_mode "p\0x00m\0x00" +# rotate motor "p\0x00r\0x01" +app_data = AppData("1b1b1b3b") +def get_app_data_input(): + version, packet_counter, message_type, *payload = app_data.get_values() + if version != 1: + return 0, 0, 0 + else: + return message_type, packet_counter, payload + + +# This is sent when a device is plugged in if it has multiple modes. +# This populates a dropdown menu in the IDE to select the mode. +def make_mode_message(port, type_id, modes): + return f"{port}\t{type_id}\tmodes\t" + "\t".join(modes) + "\r\n" + + +# BOOST Color and Distance Sensor +def update_color_and_distance_sensor(port, port_index, type_id): + sensor = ColorDistanceSensor(port) + mode_info = make_mode_message( + port, + type_id, + ["Reflected light intensity and color", "Ambient light intensity", "Distance"], + ) + while True: + # mode = app_data.get_values()[ports.index(port)] + mode = port_modes[port_index] + if mode == 0: + hsv = sensor.hsv() + intensity = sensor.reflection() + color = str(sensor.color()).replace("Color.","") + data = f"c={color}\th={hsv.h}°\ts={hsv.s}%\tv={hsv.v}%\ti={intensity}%" + elif mode == 1: + data = f"i={sensor.ambient()}%" + else: + data = f"d={sensor.distance()}%" + yield mode_info + f"{port}\t{type_id}\t{data}" + mode_info = "" + + +# SPIKE Prime / MINDSTORMS Robot Inventor Color Sensor +def update_color_sensor(port, port_index, type_id): + sensor = ColorSensor(port) + mode_info = make_mode_message( + port, + type_id, + [ + "Reflected light intensity and color", + "Ambient light intensity and color", + ], + ) + while True: + mode = port_modes[port_index] + # mode = app_data.get_values()[ports.index(port)] + hsv = sensor.hsv(False if mode else True) + color = str(sensor.color(False if mode else True)).replace("Color.","") + intensity = sensor.ambient() if mode else sensor.reflection() + data = f"c={color}\th={hsv.h}°\ts={hsv.s}%\tv={hsv.v}%\ti={intensity}%" + yield mode_info + f"{port}\t{type_id}\t{data}" + mode_info = "" + + +# WeDo 2.0 Tilt Sensor +def update_tilt_sensor(port, port_index, type_id): + sensor = TiltSensor(port) + while True: + pitch, roll = sensor.tilt() + data = f"p={pitch}°\tr={roll}°" + yield f"{port}\t{type_id}\t{data}" + + +# WeDo 2.0 Infrared Sensor +def update_infrared_sensor(port, port_index, type_id): + sensor = InfraredSensor(port) + while True: + dist = sensor.distance() + ref = sensor.reflection() + data = f"d={dist}%\ti={ref}%" + yield f"{port}\t{type_id}\t{data}" + + +# SPIKE Prime / MINDSTORMS Robot Inventor Ultrasonic Sensor +def update_ultrasonic_sensor(port, port_index, type_id): + sensor = UltrasonicSensor(port) + while True: + data = f"d={sensor.distance()}mm" + yield f"{port}\t{type_id}\t{data}" + + +# SPIKE Prime Force Sensor +def update_force_sensor(port, port_index, type_id): + sensor = ForceSensor(port) + while True: + data = f"f={sensor.force():.2f}N\td={sensor.distance():.2f}mm" + yield f"{port}\t{type_id}\t{data}" + + +# Any motor with rotation sensors. +def update_motor(port, port_index, type_id): + motor = Motor(port) + try: + while True: + angle = motor.angle() + angle_mod = motor.angle() % 360 + if angle_mod > 180: + angle_mod -= 360 + rotations = round((angle - angle_mod) / 360) + data = f"a={motor.angle()}°" + if angle != angle_mod: + data += f"\tr={rotations}R\tra={angle_mod}°" + msg = f"{port}\t{type_id}\t{data}" + + # check commands + if len(port_commands[port_index]): + command = port_commands[port_index].pop(0) + if command[0] == ord("r"): + direction = command[1] + yield motor.run_time(100 * direction, 300, Stop.COAST, wait=False) + + yield msg + except: + if motor: motor.close() + raise + + +# Any motor without rotation sensors. +def update_dc_motor(port, port_index, type_id): + motor = DCMotor(port) + try: + while True: + yield f"{port}\t{type_id}" + except: + if motor: motor.close() + raise + +# Any unknown Powered Up device. +def unknown_pup_device(port, port_index, type_id): + PUPDevice(port) + while True: + yield f"{port}\t{type_id}\tunknown" + + +# Monitoring task for one port. +def device_task(port, port_index): + + while True: + try: + # Use generic class to find device type. + dev = PUPDevice(port) + type_id = dev.info()["id"] + + # Run device specific monitoring task until it is disconnected. + if type_id == 34: + yield from update_tilt_sensor(port, port_index, type_id) + if type_id == 35: + yield from update_infrared_sensor(port, port_index, type_id) + if type_id == 37: + yield from update_color_and_distance_sensor(port, port_index, type_id) + elif type_id == 61: + yield from update_color_sensor(port, port_index, type_id) + elif type_id == 62: + yield from update_ultrasonic_sensor(port, port_index, type_id) + elif type_id == 63: + yield from update_force_sensor(port, port_index, type_id) + elif type_id in (1, 2): + yield from update_dc_motor(port, port_index, type_id) + elif type_id in (38, 46, 47, 48, 49, 65, 75, 76, 86, 87): + # 86 (0x56) Technic Move hub built-in drive motor + # 87 (0x56) Technic Move hub built-in drive motor + yield from update_motor(port, port_index, type_id) + else: + yield from unknown_pup_device(port, port_index, type_id) + except OSError as e: + # No device or previous device was disconnected. + yield f"{port}\t--" + + +# Monitoring task for the hub core. +def hub_task(): + global last_packet_counter + last_packet_counter = -1 + while True: + message_type, packet_counter, payload = get_app_data_input() + + if packet_counter != last_packet_counter: + + # execute_action + last_packet_counter = packet_counter + if message_type == ord("a"): + if payload[0] == ord("s"): + # execute_action: shutdown + try: hub.speaker.beep() + except: pass + yield hub.system.shutdown() + # port_operations + elif message_type == ord("p"): + port_index = payload[0] + port_operation = payload[1] + + # set_port_mode + if port_operation == ord("m"): + port_modes[port_index] = payload[2] + # any other port commands + else: + port_commands[port_index].append(payload[1:]) + + yield None + + +def battery_task(): + if not hub.battery: return + + count = 0 + while True: + count += 1 + if count % 100: + yield None + else: + # skip cc 10 seconds before sending an update + percentage = round(min(100,(hub.battery.voltage()-6000)/(8300-6000)*100)) + voltage = hub.battery.voltage() + status = hub.charger.status() if hub.charger else '' + data = f"pct={percentage}%\tv={voltage}mV\ts={status}" + yield f"battery\t{data}" + + +# # Monitoring task for the hub buttons. +# def buttons_task(): +# while True: +# buttons = ",".join(sorted(str(b).replace("Button.","") for b in hub.buttons.pressed())) +# yield f'buttons\t{buttons}' + + +# Monitoring task for the hub imu. +def imu_task(): + if not hub.imu: return + while True: + heading = round(hub.imu.heading()) + # [pitch, roll] = hub.imu.tilt() + pitch = round(hub.imu.tilt()[0]) + roll = round(hub.imu.tilt()[1]) + stationary = 1 if hub.imu.stationary() else 0 + up = str(hub.imu.up()).replace("Side.","") + yield f"imu\tup={up}\ty={heading}°\tp={pitch}°\tr={roll}°\ts={stationary}" + + +# Assemble all monitoring tasks. +tasks = [device_task(port, port_index) for port_index, port in enumerate(ports)] + \ + [hub_task(), battery_task(), imu_task()] + + +# Main monitoring loop. +while True: + + # Get the messages for each sensor. + msg = "" + for task in tasks: + try: + line = next(task) + if line: msg += line + "\r\n" + except Exception as e: + print("exception", e) + pass + + # REVISIT: It would be better to send whole messages (or multiples), but we + # are currently limited to 19 bytes per message, so write in chunks. + if PUPDevice: + for i in range(0, len(msg), 19): + app_data.write_bytes(msg[i : i + 19]) + else: + print(msg) + + # Loop time. + wait(100) diff --git a/src/app/App.tsx b/src/app/App.tsx index ccf420f51..f19f1f257 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors import 'react-splitter-layout/lib/index.css'; import './app.scss'; @@ -46,6 +46,19 @@ const Terminal = React.lazy(async () => { return componentModule; }); +const HubCenter = React.lazy(async () => { + const [sagaModule, componentModule] = await Promise.all([ + import('../hubcenter/sagas'), + import('../hubcenter/HubCenterDialog'), + ]); + + window.dispatchEvent( + new CustomEvent('pb-lazy-saga', { detail: { saga: sagaModule.default } }), + ); + + return componentModule; +}); + const Docs: React.FunctionComponent = () => { const { setIsSettingShowDocsEnabled } = useSettingIsShowDocsEnabled(); const { initialDocsPage, setLastDocsPage } = useAppLastDocsPageSetting(); @@ -253,6 +266,9 @@ const App: React.FunctionComponent = () => { + }> + + ); }; diff --git a/src/ble-pybricks-service/actions.ts b/src/ble-pybricks-service/actions.ts index 3b47aa9fe..97c8db3d5 100644 --- a/src/ble-pybricks-service/actions.ts +++ b/src/ble-pybricks-service/actions.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021-2023 The Pybricks Authors +// Copyright (c) 2021-2024 The Pybricks Authors // // Actions for Bluetooth Low Energy Pybricks service @@ -59,12 +59,15 @@ export const sendStopUserProgramCommand = createAction((id: number) => ({ * Action that requests a start user program to be sent. * @param id Unique identifier for this transaction. * - * @since Pybricks Profile v1.2.0 + * @since Pybricks Profile v1.2.0. Program identifier added in Pybricks Profile v1.4.0. */ -export const sendStartUserProgramCommand = createAction((id: number) => ({ - type: 'blePybricksServiceCommand.action.sendStartUserProgram', - id, -})); +export const sendStartUserProgramCommand = createAction( + (id: number, slot?: number) => ({ + type: 'blePybricksServiceCommand.action.sendStartUserProgram', + id, + slot, + }), +); /** * Action that requests a start interactive REPL to be sent. @@ -124,6 +127,23 @@ export const sendWriteStdinCommand = createAction( }), ); +/** + * Action that requests to write to appdata. + * @param id Unique identifier for this transaction. + * @param offset offset: The offset from the buffer base address + * @param payload The bytes to write. + * + * @since Pybricks Profile v1.4.0. + */ +export const sendWriteAppDataCommand = createAction( + (id: number, offset: number, payload: ArrayBuffer) => ({ + type: 'blePybricksServiceCommand.action.sendWriteAppDataCommand', + id, + offset, + payload, + }), +); + /** * Action that indicates that a command was successfully sent. * @param id Unique identifier for the transaction from the corresponding "send" command. @@ -156,8 +176,8 @@ export const didReceiveStatusReport = createAction((statusFlags: number) => ({ })); /** - * Action that represents a status report event received from the hub. - * @param statusFlags The status flags. + * Action that represents write to stdin received from the hub. + * @param payload The piece of message received. * * @since Pybricks Profile v1.3.0 */ @@ -166,6 +186,17 @@ export const didReceiveWriteStdout = createAction((payload: ArrayBuffer) => ({ payload, })); +/** + * Action that represents a write to a buffer that is pre-allocated by a user program received from the hub. + * @param payload The piece of message received. + * + * @since Pybricks Profile v1.4.0 + */ +export const didReceiveWriteAppData = createAction((payload: ArrayBuffer) => ({ + type: 'blePybricksServiceEvent.action.didReceiveWriteAppData', + payload, +})); + /** * Pseudo-event = actionCreator((not received from hub) indicating that there was a protocol error. * @param error The error that was caught. diff --git a/src/ble-pybricks-service/protocol.ts b/src/ble-pybricks-service/protocol.ts index cf57f4c6a..66bac4045 100644 --- a/src/ble-pybricks-service/protocol.ts +++ b/src/ble-pybricks-service/protocol.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors // // Definitions related to the Pybricks Bluetooth low energy GATT service. @@ -25,7 +25,15 @@ export enum CommandType { /** * Request to start the user program. * - * @since Pybricks Profile v1.2.0 + * The optional payload parameter was added in Pybricks Profile v1.4.0. + * + * Parameters: + * - payload: Optional program identifier (one byte). Slots 0--127 are + * reserved for downloaded user programs. Slots 128--255 are + * for builtin user programs. If no program identifier is + * given, the currently active program slot will be started. + * + * @since Pybricks Profile v1.2.0. Program identifier added in Pybricks Profile v1.4.0. */ StartUserProgram = 1, /** @@ -58,6 +66,17 @@ export enum CommandType { * @since Pybricks Profile v1.3.0 */ WriteStdin = 6, + /** + * Requests to write to a buffer that is pre-allocated by a user program. + * + * Parameters: + * - offset: The offset from the buffer base address (16-bit little-endian + * unsigned integer). + * - payload: The data to write. + * + * @since Pybricks Profile v1.4.0 + */ + WriteAppData = 7, } /** @@ -76,9 +95,12 @@ export function createStopUserProgramCommand(): Uint8Array { * * @since Pybricks Profile v1.2.0 */ -export function createStartUserProgramCommand(): Uint8Array { - const msg = new Uint8Array(1); +export function createStartUserProgramCommand(slot: number | undefined): Uint8Array { + const msg = new Uint8Array(slot === undefined ? 1 : 2); msg[0] = CommandType.StartUserProgram; + if (slot !== undefined) { + msg[1] = slot & 0xff; + } return msg; } @@ -140,6 +162,25 @@ export function createWriteStdinCommand(payload: ArrayBuffer): Uint8Array { return msg; } +/** + * Creates a {@link CommandType.WriteAppData} message. + * @param offset The offset from the buffer base address + * @param payload The bytes to write. + * + * @since Pybricks Profile v1.4.0. + */ +export function createWriteAppDataCommand( + offset: number, + payload: ArrayBuffer, +): Uint8Array { + const msg = new Uint8Array(1 + 2 + payload.byteLength); + const view = new DataView(msg.buffer); + view.setUint8(0, CommandType.WriteAppData); + view.setUint8(1, offset & 0xffff); + msg.set(new Uint8Array(payload), 3); + return msg; +} + /** Events are notifications received from the hub. */ export enum EventType { /** @@ -147,7 +188,11 @@ export enum EventType { * * Received when notifications are enabled and when status changes. * - * @since Pybricks Profile v1.0.0 + * The payload is one 32-bit little-endian unsigned integer containing + * ::pbio_pybricks_status_t flags and a one byte program identifier + * representing the currently active program if it is running. + * + * @since Pybricks Profile v1.0.0. Program identifier added in Pybricks Profile v1.4.0. */ StatusReport = 0, /** @@ -156,6 +201,12 @@ export enum EventType { * @since Pybricks Profile v1.3.0 */ WriteStdout = 1, + /** + * Hub wrote to appdata event. + * + * @since Pybricks Profile v1.4.0 + */ + WriteAppData = 2, } /** Status indications received by Event.StatusReport */ @@ -244,6 +295,18 @@ export function parseWriteStdout(msg: DataView): ArrayBuffer { return msg.buffer.slice(1); } +/** + * Parses the payload of a app data message. + * @param msg The raw message data. + * @returns The bytes that were written. + * + * @since Pybricks Profile v1.4.0 + */ +export function parseWriteAppData(msg: DataView): ArrayBuffer { + assert(msg.getUint8(0) === EventType.WriteAppData, 'expecting write appdata event'); + return msg.buffer.slice(1); +} + /** * Protocol error. Thrown e.g. when there is a malformed message. */ @@ -285,6 +348,20 @@ export enum HubCapabilityFlag { * @since Pybricks Profile v1.3.0 */ UserProgramMultiMpy6Native6p1 = 1 << 2, + + /** + * Hub supports builtin sensor port view monitoring program. + * + * @since Pybricks Profile v1.4.0. + */ + HasPortView = 1 << 3, + + /** + * Hub supports builtin IMU calibration program. + * + * @since Pybricks Profile v1.4.0. + */ + HasIMUCalibration = 1 << 4, } /** Supported user program file formats. */ diff --git a/src/ble-pybricks-service/sagas.ts b/src/ble-pybricks-service/sagas.ts index f1477bb38..dc517127a 100644 --- a/src/ble-pybricks-service/sagas.ts +++ b/src/ble-pybricks-service/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021-2023 The Pybricks Authors +// Copyright (c) 2021-2024 The Pybricks Authors // // Handles Pybricks protocol. @@ -18,6 +18,7 @@ import { didFailToWriteCommand, didNotifyEvent, didReceiveStatusReport, + didReceiveWriteAppData, didReceiveWriteStdout, didSendCommand, didWriteCommand, @@ -25,6 +26,7 @@ import { sendStartReplCommand, sendStartUserProgramCommand, sendStopUserProgramCommand, + sendWriteAppDataCommand, sendWriteStdinCommand, sendWriteUserProgramMetaCommand, sendWriteUserRamCommand, @@ -36,11 +38,13 @@ import { createStartReplCommand, createStartUserProgramCommand, createStopUserProgramCommand, + createWriteAppDataCommand, createWriteStdinCommand, createWriteUserProgramMetaCommand, createWriteUserRamCommand, getEventType, parseStatusReport, + parseWriteAppData, parseWriteStdout, } from './protocol'; @@ -64,7 +68,9 @@ function* encodeRequest(): Generator { if (sendStopUserProgramCommand.matches(action)) { yield* put(writeCommand(action.id, createStopUserProgramCommand())); } else if (sendStartUserProgramCommand.matches(action)) { - yield* put(writeCommand(action.id, createStartUserProgramCommand())); + yield* put( + writeCommand(action.id, createStartUserProgramCommand(action.slot)), + ); } else if (sendStartReplCommand.matches(action)) { yield* put(writeCommand(action.id, createStartReplCommand())); } else if (sendWriteUserProgramMetaCommand.matches(action)) { @@ -82,6 +88,13 @@ function* encodeRequest(): Generator { yield* put( writeCommand(action.id, createWriteStdinCommand(action.payload)), ); + } else if (sendWriteAppDataCommand.matches(action)) { + yield* put( + writeCommand( + action.id, + createWriteAppDataCommand(action.offset, action.payload), + ), + ); } else { console.error(`Unknown Pybricks service command ${action.type}`); continue; @@ -114,6 +127,9 @@ function* decodeResponse(action: ReturnType): Generator { case EventType.WriteStdout: yield* put(didReceiveWriteStdout(parseWriteStdout(action.value))); break; + case EventType.WriteAppData: + yield* put(didReceiveWriteAppData(parseWriteAppData(action.value))); + break; default: throw new ProtocolError( `unknown pybricks event type: ${hex(responseType, 2)}`, diff --git a/src/ble/sagas.ts b/src/ble/sagas.ts index 00be5fd3a..8b7832d07 100644 --- a/src/ble/sagas.ts +++ b/src/ble/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors // // Manages connection to a Bluetooth Low Energy device running Pybricks firmware. @@ -63,7 +63,7 @@ import { ensureError } from '../utils'; import { isLinux } from '../utils/os'; import { pythonVersionToSemver } from '../utils/version'; import { - bleConnectPybricks as bleConnectPybricks, + bleConnectPybricks, bleDidConnectPybricks, bleDidDisconnectPybricks, bleDidFailToConnectPybricks, @@ -73,7 +73,7 @@ import { import { BleConnectionState } from './reducers'; /** The version of the Pybricks Profile version currently implemented by this file. */ -export const supportedPybricksProfileVersion = '1.3.0'; +export const supportedPybricksProfileVersion = '1.4.0'; const decoder = new TextDecoder(); diff --git a/src/hub/reducers.ts b/src/hub/reducers.ts index 5ddb73287..0314ad3e4 100644 --- a/src/hub/reducers.ts +++ b/src/hub/reducers.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors import { Reducer, combineReducers } from 'redux'; import * as semver from 'semver'; @@ -209,6 +209,17 @@ const hasRepl: Reducer = (state = false, action) => { return state; }; +/** + * Indicates if the connected hub supports a Port View. + */ +const hasPortView: Reducer = (state = false, action) => { + if (blePybricksServiceDidReceiveHubCapabilities.matches(action)) { + return Boolean(action.flags & HubCapabilityFlag.HasPortView); + } + + return state; +}; + /** * The preferred file format of the connected hub or null if the hub does not * support any file formats that Pybricks Code supports. @@ -269,6 +280,7 @@ export default combineReducers({ maxBleWriteSize, maxUserProgramSize, hasRepl, + hasPortView, preferredFileFormat, useLegacyDownload, useLegacyStdio, diff --git a/src/hubcenter/HubCenterContext.tsx b/src/hubcenter/HubCenterContext.tsx new file mode 100644 index 000000000..2311e4466 --- /dev/null +++ b/src/hubcenter/HubCenterContext.tsx @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021-2024 The Pybricks Authors +// +// Shared terminal context. + +import { createContext } from 'react'; +import PushStream from 'zen-push'; + +/** The default hubcenter context. */ +export const defaultHubCenterContext = { + dataSource: new PushStream(), +}; + +/** Hubcenter context data type. */ +export type HubCenterContextValue = typeof defaultHubCenterContext; + +/** Hubcenter React context. */ +export const HubCenterContext = createContext(defaultHubCenterContext); diff --git a/src/hubcenter/HubCenterDialog.tsx b/src/hubcenter/HubCenterDialog.tsx new file mode 100644 index 000000000..5fe5b15ab --- /dev/null +++ b/src/hubcenter/HubCenterDialog.tsx @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022-2024 The Pybricks Authors + +import { Classes, Dialog, Icon } from '@blueprintjs/core'; +import { Lightning, Power } from '@blueprintjs/icons'; +import classNames from 'classnames'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDispatch } from 'react-redux'; +import { useEventCallback } from 'usehooks-ts'; +import { Button } from '../components/Button'; +import { useSelector } from '../reducers'; +import { HubCenterContext } from './HubCenterContext'; +import PortComponent, { PortData } from './PortComponent'; +import { executeAppDataCommand, hubcenterHideDialog } from './actions'; +import './hub-center-dialog.scss'; +import { useI18n } from './i18n'; +import HubIconComponent, { getHubPortCount } from './icons/HubCenterIcon'; + +const HubcenterDialog: React.FunctionComponent = () => { + const { showDialog, deviceName, deviceType, deviceFirmwareVersion } = useSelector( + (s) => ({ + showDialog: s.hubcenter.showDialog, + deviceName: s.ble.deviceName, + deviceType: s.ble.deviceType, + deviceFirmwareVersion: s.ble.deviceFirmwareVersion, + }), + ); + + const hubcenterStream = useContext(HubCenterContext); + const [hubBattery, setHubBattery] = useState(''); + const [hubBatteryCharger, setHubBatteryCharger] = useState(false); + const [hubImuData, setHubImuData] = useState(''); + const portDataRef = useRef(new Map()); + const portModesRef = useRef(new Map()); + const [portData, setPortData] = useState(new Map()); + const dispatch = useDispatch(); + const i18n = useI18n(); + const subscriptionRef = useRef(null); + const partialMessageRef = useRef(''); + + // NOTE: port data reference contains the current value, subscription should be initied only on mount, + // and not be updated when it changes, while portData/setPortData will be updated on every message + // and triggers the component UI update. + + const parseArrayToMap = (input: string[]): Map => { + return input.reduce((map, pair) => { + const [key, value] = pair.split('='); + if (key && value) { + map.set(key.trim(), value.trim()); + } + return map; + }, new Map()); + }; + + const processMessage = useCallback((message: string) => { + const [key, ...dataraw] = message.split('\t'); + const dataMap = parseArrayToMap(dataraw); + + switch (key) { + case 'battery': + setHubBattery(dataMap.get('pct') ?? ''); + setHubBatteryCharger(parseInt(dataMap.get('s') ?? '') > 0); + break; + case 'imu': + setHubImuData(dataraw.join(', ')); + break; + default: + if (key.startsWith('Port.')) { + const port = key; + const puptype = parseInt(dataraw[0]) ?? 0; + const dataStr = dataraw.slice(1).join(', '); + + const portdata = + portDataRef.current.get(port) ?? + ({ + type: puptype, + dataMap: new Map(), + } as PortData); + portdata.type = puptype; + + if (!dataStr || puptype === 0) { + portDataRef.current.delete(port); + portModesRef.current.delete(port); + } else if (dataraw[1] === 'modes') { + portModesRef.current.set(port, dataraw.slice(2)); + } else { + portdata.dataMap = dataMap; + portdata.dataStr = dataStr; + portDataRef.current.set(port, portdata); + } + + setPortData(new Map(portDataRef.current)); + } + break; + } + }, []); + + useEffect(() => { + subscriptionRef.current = hubcenterStream.dataSource.observable.subscribe({ + next: (d) => { + const combinedMessage = partialMessageRef.current + d; + const parts = combinedMessage.split('\n'); + + // Process all complete messages + for (let i = 0; i < parts.length - 1; i++) { + const message = parts[i].trim(); + if (message) { + processMessage(message); + } + } + + // Remember any partial leftover + partialMessageRef.current = parts[parts.length - 1]; + }, + }); + + // Cleanup subscription on unmount + return () => subscriptionRef.current?.unsubscribe(); + }, [hubcenterStream.dataSource.observable, processMessage]); + + const portComponents = useMemo(() => { + return [...Array(getHubPortCount(deviceType)).keys()].map((idx: number) => { + const portLabel = String.fromCharCode(65 + idx); // A, B, C, D, E, F + const side = idx % 2 === 0 ? 'left' : 'right'; + return ( + + ); + }); + }, [deviceType, portData]); + + const handleShutdown = useEventCallback(() => { + const msg = new Uint8Array(['a'.charCodeAt(0), 's'.charCodeAt(0)]); + dispatch(executeAppDataCommand(msg)); + // TODO: workaround: should be a didExecutedAppCommand + setTimeout(() => dispatch(hubcenterHideDialog()), 300); + }); + + return ( + dispatch(hubcenterHideDialog())} + > +
+

+ {deviceName} + + + {deviceType}, {deviceFirmwareVersion}, {hubBattery} + {hubBatteryCharger && } />} + +

+ +
+ + {portComponents} +
+
+
+
+
+ ); +}; + +export default HubcenterDialog; diff --git a/src/hubcenter/PortComponent.tsx b/src/hubcenter/PortComponent.tsx new file mode 100644 index 000000000..591930b5c --- /dev/null +++ b/src/hubcenter/PortComponent.tsx @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2024 The Pybricks Authors + +import { AnchorButton, Button, ButtonGroup } from '@blueprintjs/core'; +import { CaretDown, Repeat, Reset } from '@blueprintjs/icons'; +import React, { useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { useEventCallback } from 'usehooks-ts'; +import { executeAppDataCommand } from './actions'; +import ColorSensorIconComponent from './icons/ColorSensorIcon'; +import DeviceIcon from './icons/DeviceIcon'; +import MotorIcon from './icons/MotorIcon'; +import boostColorDistanceSensor from './icons/boost_color_distance_sensor_37.png'; +// import wedoMediumMotor from './icons/wedo_2_0_medium_motor_1.png'; +// import poweredUpTrainMotor from './icons/powered_up_train_motor_2.png'; +// import poweredUpLight from './icons/8_powered_up_light.png'; +import boostInteractiveMotor from './icons/boost_interactive_motor_38.png'; +import boostInteractiveMotorShaft from './icons/boost_interactive_motor_38_shaft.png'; +import spikeColorSensor from './icons/spike_color_sensor_61.png'; +import spikeForceSensor from './icons/spike_force_sensor_63.png'; +// import spikeColorLightMatrix from './icons/spike_3x3_color_light_matrix_64.png'; +import spikeLargeAngularMotor from './icons/spike_large_angular_motor_49.png'; +import spikeLargeAngularMotorShaft from './icons/spike_large_angular_motor_49_shaft.png'; +import spikeMediumAngularMotor from './icons/spike_medium_angular_motor_48.png'; +import spikeMediumAngularMotorShaft from './icons/spike_medium_angular_motor_48_shaft.png'; +import spikeSmallAngularMotor from './icons/spike_small_angular_motor_65.png'; +import spikeUltrasonicSensor from './icons/spike_ultrasonic_sensor_62.png'; +import technicLargeAngularMotor from './icons/technic_large_angular_motor_76.png'; +import technicLargeAngularMotorShaft from './icons/technic_large_angular_motor_76_shaft.png'; +import technicMediumAngularMotor from './icons/technic_medium_angular_motor_75.png'; +import technicMediumAngularMotorShaft from './icons/technic_medium_angular_motor_75_shaft.png'; +import wedoInfraredMotionSensor from './icons/wedo_2_0_infrared_motion_sensor_35.png'; +import wedoTiltSensor from './icons/wedo_2_0_tilt_sensor_34.png'; +// import technicLargeMotor from './icons/technic_large_motor_46.png'; +// import technicExtraLargeMotor from './icons/technic_extra_large_motor_47.png'; + +interface PortComponentProps { + portCode: string; + portIndex: number; + side: 'left' | 'right'; + data: Map; + modes: Map; +} + +export interface PortData { + type: number | undefined; + lastUpdated?: Date; + dataMap: Map | undefined; + dataStr: string; +} + +export interface DeviceRegistryEntry { + name: string; + icon?: string; + iconShaft?: string; + classShaft?: string; + canRotate?: boolean; +} + +const DeviceRegistry = new Map([ + [1, { name: 'Wedo 2.0 Medium Motor' }], //, wedoMediumMotor], + [2, { name: 'Powered Up Train Motor' }], //, poweredUpTrainMotor], + [8, { name: 'Powered Up Light' }], //, poweredUpLight], + [34, { name: 'Wedo 2.0 Tilt Sensor', icon: wedoTiltSensor }], + [35, { name: 'Wedo 2.0 Infrared Motion Sensor', icon: wedoInfraredMotionSensor }], + [37, { name: 'BOOST Color Distance Sensor', icon: boostColorDistanceSensor }], + [ + 38, + { + name: 'BOOST Interactive Motor', + icon: boostInteractiveMotor, + iconShaft: boostInteractiveMotorShaft, + classShaft: 'motor-shaft-centered', + canRotate: false, + }, + ], + [46, { name: 'Technic Large Motor' }], //, technicLargeMotor], + [47, { name: 'Technic Extra Large Motor' }], //, technicExtraLargeMotor], + [ + 48, + { + name: 'SPIKE Medium Angular Motor', + icon: spikeMediumAngularMotor, + iconShaft: spikeMediumAngularMotorShaft, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], + [ + 49, + { + name: 'SPIKE Large Angular Motor', + icon: spikeLargeAngularMotor, + iconShaft: spikeLargeAngularMotorShaft, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], + [61, { name: 'SPIKE Color Sensor', icon: spikeColorSensor }], + [62, { name: 'SPIKE Ultrasonic Sensor', icon: spikeUltrasonicSensor }], + [63, { name: 'SPIKE Force Sensor', icon: spikeForceSensor }], + [64, { name: 'SPIKE 3x3 Color Light Matrix' }], //, spikeColorLightMatrix], + [ + 65, + { + name: 'SPIKE Small Angular Motor', + icon: spikeSmallAngularMotor, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], + [ + 75, + { + name: 'Technic Medium Angular Motor', + icon: technicMediumAngularMotor, + iconShaft: technicMediumAngularMotorShaft, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], + [ + 76, + { + name: 'Technic Large Angular Motor', + icon: technicLargeAngularMotor, + iconShaft: technicLargeAngularMotorShaft, + classShaft: 'motor-shaft-start', + canRotate: true, + }, + ], +]); + +const PortComponent: React.FunctionComponent = ({ + portCode, + portIndex, + side, + data, + modes, +}) => { + const portModeRef = useRef(0); + const dispatch = useDispatch(); + + const portId = 'Port.' + portCode; + const portData = data.get(portId); + const portModes = modes.get(portId); + const devEntry = DeviceRegistry.get(portData?.type ?? 0); + + // const iconComponent = useMemo(() => { + // if (devEntry?.iconShaft) { + // return ; + // } else if (portData?.type === 61 || portData?.type === 37) { + // return ; + // } else { + // return ; + // } + // }, [portData, devEntry, side]); + + const getIconComponent = () => { + if (devEntry?.iconShaft) { + return ( + <> + + + - - ); + ); + } else { + return ( + + + + + + {i18n.translate('hubInfo.connectedTo')} + + + {deviceName} + + + + {i18n.translate('hubInfo.hubType')} + + {deviceType} + + + + + {i18n.translate('hubInfo.firmware')} + + + v{deviceFirmwareVersion} + + + + } + > + + + ); + } }; const BatteryIndicator: React.FunctionComponent = () => { diff --git a/src/status-bar/translations/en.json b/src/status-bar/translations/en.json index 1fa3ea669..3061d3da0 100644 --- a/src/status-bar/translations/en.json +++ b/src/status-bar/translations/en.json @@ -14,6 +14,9 @@ "hubType": "Hub type:", "firmware": "Firmware:" }, + "hubCenter": { + "title": "Hub Center (F7)" + }, "battery": { "title": "Battery", "low": "Battery is low. Hub will turn off soon.", diff --git a/src/toolbar/Toolbar.tsx b/src/toolbar/Toolbar.tsx index efe8bb6ff..49c917cf7 100644 --- a/src/toolbar/Toolbar.tsx +++ b/src/toolbar/Toolbar.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2024 The Pybricks Authors import { ButtonGroup } from '@blueprintjs/core'; import React from 'react';