import { createElement, useCallback, useEffect, useRef, useState } from "react"; import GraphArea from "../GraphArea"; import styles from "./styles.module.css"; import { FaPlus, FaChartLine, FaPlug, FaCheckCircle, FaUnlink, } from "react-icons/fa"; import MicroBitIcon from "../../assets/connect-button/microbit-small.svg"; import modalState from "../../state-observables/modal/ModalState"; import NewGraphModal from "../modals/NewGraphModal"; import RAFT from "@robotical/webapp-types/dist-types/src/application/RAFTs/RAFT"; import { resolveRaftDisplayName } from "../../utils/raft-display-name"; import MicroBitWebBluetooth, { isMicroBitDevice, isMicroBitWebBluetoothSupported, } from "../../microbit/MicroBitWebBluetooth"; interface GraphObj { graphId: string; element: React.ReactNode; } type Props = { mainRef: React.RefObject; } type ConnectionPhase = "idle" | "connecting" | "error"; function MainContent({ mainRef }: Props) { const [hasConnectedRafts, setHasConnectedRafts] = useState(false); const [connectionPhase, setConnectionPhase] = useState("idle"); const [connectionMessage, setConnectionMessage] = useState(""); const [microBit, setMicroBit] = useState(null); const [microBitPhase, setMicroBitPhase] = useState("idle"); const [microBitMessage, setMicroBitMessage] = useState(""); const [, triggerRerender] = useState(0); const graphs = useRef([]); const graphSubRefs = useRef void>>({}); const microBitRef = useRef(null); const pendingMicroBitRef = useRef(null); const microBitDisconnectCleanup = useRef<(() => void) | null>(null); const isMounted = useRef(true); const isConnected = hasConnectedRafts || Boolean(microBit); const canConnectMicroBit = isMicroBitWebBluetoothSupported(); const removeGraph = (graphId: string) => { const cleanup = graphSubRefs.current[graphId]; if (cleanup) { cleanup(); delete graphSubRefs.current[graphId]; } const graphsUpdated = graphs.current.filter((graph) => graph.graphId !== graphId); graphs.current = graphsUpdated; triggerRerender(old => old + 1); }; const syncConnectionState = useCallback(() => { const connectedRafts = window.applicationManager?.connectedRafts || {}; const connected = Object.keys(connectedRafts).length > 0; setHasConnectedRafts(connected); if (connected) { setConnectionPhase("idle"); setConnectionMessage(""); } return connected; }, []); useEffect(() => { syncConnectionState(); const connectedRaftInterval = setInterval(() => { syncConnectionState(); }, 750); return () => { clearInterval(connectedRaftInterval); } }, [syncConnectionState]); useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; Object.values(graphSubRefs.current).forEach((cleanup) => cleanup()); graphSubRefs.current = {}; microBitDisconnectCleanup.current?.(); microBitDisconnectCleanup.current = null; microBitRef.current?.disconnect(); pendingMicroBitRef.current?.disconnect(); microBitRef.current = null; pendingMicroBitRef.current = null; }; }, []); const connectRobot = async () => { if (!window.applicationManager) { setConnectionPhase("error"); setConnectionMessage("Device connections are not available in this view."); return; } setConnectionPhase("connecting"); setConnectionMessage("Choose your device in the connection window."); try { await window.applicationManager.connectGeneric(() => { syncConnectionState(); }); const connected = syncConnectionState(); if (!connected) { setConnectionPhase("idle"); setConnectionMessage("No device was connected. You can try again when you are ready."); } } catch (error) { console.error("Unable to connect robot", error); setConnectionPhase("error"); setConnectionMessage("We could not connect to the device. Please try again."); } }; const connectMicroBit = async () => { if (!isMicroBitWebBluetoothSupported()) { setMicroBitPhase("error"); setMicroBitMessage("Web Bluetooth is not available in this browser."); return; } if (microBitRef.current?.isConnected() || pendingMicroBitRef.current) { return; } const candidate = new MicroBitWebBluetooth(); pendingMicroBitRef.current = candidate; setMicroBitPhase("connecting"); setMicroBitMessage("Choose your micro:bit in the Bluetooth window."); try { await candidate.connect(); if (!isMounted.current || pendingMicroBitRef.current !== candidate) { candidate.disconnect(); return; } if (!candidate.isConnected()) { throw new Error("The micro:bit disconnected while connecting."); } const unsubscribeDisconnect = candidate.addDisconnectListener( (disconnectedMicroBit) => { if (microBitRef.current !== disconnectedMicroBit) return; microBitDisconnectCleanup.current?.(); microBitDisconnectCleanup.current = null; microBitRef.current = null; if (isMounted.current) { setMicroBit(null); setMicroBitPhase("idle"); setMicroBitMessage("micro:bit disconnected."); } } ); microBitDisconnectCleanup.current = unsubscribeDisconnect; microBitRef.current = candidate; setMicroBit(candidate); setMicroBitPhase("idle"); setMicroBitMessage(""); } catch (error) { if (!isMounted.current) return; const wasCancelled = (error as { name?: string } | null)?.name === "NotFoundError"; setMicroBitPhase(wasCancelled ? "idle" : "error"); setMicroBitMessage( wasCancelled ? "No micro:bit was selected." : "Could not connect. Check that the micro:bit is powered on and running the Robotical firmware." ); } finally { if (pendingMicroBitRef.current === candidate) { pendingMicroBitRef.current = null; } } }; const disconnectMicroBit = () => { const connectedMicroBit = microBitRef.current; if (!connectedMicroBit) return; microBitDisconnectCleanup.current?.(); microBitDisconnectCleanup.current = null; microBitRef.current = null; setMicroBit(null); setMicroBitPhase("idle"); setMicroBitMessage("micro:bit disconnected."); connectedMicroBit.disconnect(); }; const addGraphHandler = async () => { const deviceId = await modalState.setModal( createElement(NewGraphModal, { microBit }), "Add new graph" ); if (!deviceId) { return; } const device = microBit?.id === deviceId ? microBit : window.applicationManager?.connectedRafts?.[deviceId]; if (!device) { return; } if (isMicroBitDevice(device) && !device.isConnected()) { setMicroBitMessage("The micro:bit disconnected before the graph was created."); return; } const deviceName = isMicroBitDevice(device) ? device.getFriendlyName() : await resolveRaftDisplayName( device, window.applicationManager?.connectedRaftsContext || [] ); const graphsUpdated = [...graphs.current]; const GRAPH_ID = new Date().getTime().toString(); const disconnectCb = () => { removeGraph(GRAPH_ID); }; if (isMicroBitDevice(device)) { graphSubRefs.current[GRAPH_ID] = device.addDisconnectListener(disconnectCb); } else { const mgr = getOrCreateRaftDisconnectManager(deviceId, device); mgr.subscribe(disconnectCb); graphSubRefs.current[GRAPH_ID] = () => mgr.unsubscribe(disconnectCb); } graphsUpdated.push({ graphId: GRAPH_ID, element: ( ), }); graphs.current = graphsUpdated; triggerRerender(old => old + 1); }; if (!isConnected) { return (

Start here

Connect a device to see its sensors

Pair Marty, Cog, or a micro:bit, then choose the signals you want to turn into a live graph.

{connectionMessage && (

{connectionMessage}

)} {microBitMessage && (

{microBitMessage}

)}
  1. 1
    ConnectPair Marty, Cog, or micro:bit.
  2. 2
    Add a graphChoose the connected device.
  3. 3
    Pick signalsStart recording live data.
); } const hasGraphs = graphs.current.length > 0; return (

Graphs

{hasGraphs ? `${graphs.current.length} live workspace${graphs.current.length === 1 ? "" : "s"}` : "Create a graph to begin exploring sensor data."}

{microBit ? ( ) : ( )}
{microBitMessage && (

{microBitMessage}

)}
{graphs.current.map((graphArea) => { return graphArea.element; })}
{!hasGraphs && (
)}
); } export default MainContent; type ObserverType = { notify: (eventType: string, eventEnum: any, eventName: string, eventData: any) => void; }; type RaftDisconnectManager = { subscribe: (cb: () => void) => void; unsubscribe: (cb: () => void) => void; }; // One subscription per raft, with fan-out to all registered graph callbacks const raftManagers: Record void>; observer: ObserverType; raft: RAFT }> = {}; const getOrCreateRaftDisconnectManager = (raftId: string, raft: RAFT): RaftDisconnectManager => { if (!raftManagers[raftId]) { const callbacks = new Set<() => void>(); const observer: ObserverType = { notify(eventType: string, eventEnum: any) { switch (eventType) { case "conn": switch (eventEnum) { case 3: // BLE_DISCONNECTED console.log("Marty Disconnected (fan-out)!!!!!!!!!"); // Copy to avoid mutation during iteration const toCall = Array.from(callbacks); callbacks.clear(); toCall.forEach((cb) => { try { cb(); } catch (e) { console.error(e); } }); // Tear down subscription for this raft raft.unsubscribe(observer); delete raftManagers[raftId]; break; default: break; } break; default: break; } }, }; raft.subscribe(observer, ["conn"]); raftManagers[raftId] = { callbacks, observer, raft }; } return { subscribe: (cb: () => void) => { raftManagers[raftId].callbacks.add(cb); }, unsubscribe: (cb: () => void) => { const mgr = raftManagers[raftId]; if (!mgr) return; mgr.callbacks.delete(cb); if (mgr.callbacks.size === 0) { // No more listeners; clean up subscription mgr.raft.unsubscribe(mgr.observer); delete raftManagers[raftId]; } }, }; };