diff --git a/.env.development b/.env.development
new file mode 100644
index 0000000000000000000000000000000000000000..b26185bf9239df12815974a74ccf5a29f7e32334
--- /dev/null
+++ b/.env.development
@@ -0,0 +1,3 @@
+HTTPS=true
+SSL_CRT_FILE=development-certificate/bw2.crt
+SSL_KEY_FILE=development-certificate/bw2.key
diff --git a/.gitignore b/.gitignore
index 4d29575de80483b005c29bfcac5061cd2f45313e..c4c8057fefd0b2ef044e26b8caf21878aa0934df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,6 @@
 npm-debug.log*
 yarn-debug.log*
 yarn-error.log*
+
+# generated files
+src/schemas
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000000000000000000000000000000000000..27742a7019c8f91492ccadee446fabb27e4bcc67
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+[submodule "config-schemas"]
+	path = src/config-schemas
+	url = https://gitlab.georgiatech-metz.fr/soehrl/config-schemas.git
+[submodule "development-certificate"]
+	path = development-certificate
+	url = ../development-certificate.git
diff --git a/development-certificate b/development-certificate
new file mode 160000
index 0000000000000000000000000000000000000000..988b7580a51672290bc64591cb9bcdf2c7108b44
--- /dev/null
+++ b/development-certificate
@@ -0,0 +1 @@
+Subproject commit 988b7580a51672290bc64591cb9bcdf2c7108b44
diff --git a/package.json b/package.json
index e1a96e504926f2af49f02d71bb8af3bbb1c1bc82..7eef0e9f49256d2bd7c5618120adeb5d070cb62e 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
 {
-  "name": "shipt-it",
+  "name": "ship-it",
   "version": "0.1.0",
   "private": true,
   "dependencies": {
@@ -22,6 +22,7 @@
     "@types/react-router-dom": "^5.3.3",
     "@types/roslib": "^1.1.10",
     "@types/three": "^0.144.0",
+    "jsonschema": "^1.4.1",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-redux": "^8.0.2",
@@ -31,13 +32,26 @@
     "roslib": "^1.3.0",
     "three": "^0.144.0",
     "typescript": "^4.4.2",
-    "web-vitals": "^2.1.0"
+    "url": "^0.11.0",
+    "web-vitals": "^2.1.0",
+    "zustand": "^4.1.4"
   },
   "scripts": {
-    "start": "react-scripts start",
-    "build": "react-scripts build",
+    "start": "concurrently 'react-scripts start' 'yarn run watch'",
+    "build": "yarn run convert-schemas && react-scripts build",
     "test": "react-scripts test",
-    "eject": "react-scripts eject"
+    "eject": "react-scripts eject",
+    "convert-schemas": "json2ts -i 'src/config-schemas/*' -o 'src/schemas/'",
+    "watch": "npm-watch"
+  },
+  "watch": {
+    "convert-schemas": {
+      "patterns": [
+        "src/config-schemas/"
+      ],
+      "extensions": "schema.json",
+      "delay": 100
+    }
   },
   "eslintConfig": {
     "extends": [
@@ -58,6 +72,9 @@
     ]
   },
   "devDependencies": {
-    "@types/react-swipeable-views": "^0.13.1"
+    "@types/react-swipeable-views": "^0.13.1",
+    "concurrently": "^7.5.0",
+    "json-schema-to-typescript": "^11.0.2",
+    "npm-watch": "^0.11.0"
   }
 }
diff --git a/src/App.tsx b/src/App.tsx
index 249096f36832188bbb82952c413ca588b39d7248..f02dce53cea79bfafd662b7bb9a4e85bbfb76dcc 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,15 +1,16 @@
 import { useEffect, useState } from 'react'
-import { useNavigate } from 'react-router-dom';
-import { useAppDispatch, useAppSelector } from './app/hooks';
 import { createTheme } from '@mui/material';
 import { Box, ThemeProvider } from '@mui/system';
-import { configUpdated } from './features/config/config-slice';
 import AppBar from './app/MenuBar';
-import { useNonInitialEffect } from './hooks/useNonInitialEffect';
 import Viewport from './features/viewport/Viewport';
 import RobotList from './features/robots/RobotList';
 import { Split } from '@geoffcox/react-splitter';
-import Viewport2D from './features/viewport/Viewport2D';
+import { validate } from 'jsonschema';
+import ConfigSchema from './config-schemas/Config.schema.json';
+import { Config } from './schemas/Config.schema';
+import RobotSchema from './config-schemas/Robot.schema.json';
+import { Robot } from './schemas/Robot.schema';
+import { useStore } from './common/store';
 
 const darkTheme = createTheme({
   palette: {
@@ -24,28 +25,69 @@ const lightTheme = createTheme({
 });
 
 function App() {
-  const config = useAppSelector(state => state.config);
-  const dispatch = useAppDispatch();
-  const navigate = useNavigate();
+  // const config = useAppSelector(state => state.config);
+  const addRobot = useStore(state => state.addRobot);
+  const updateMessageRates = useStore(state => state.updateMessageRates);
+  // const navigate = useNavigate();
   const [darkMode, setDarkMode] = useState(true);
 
-  useEffect(() => {
-    const queryParams = new URLSearchParams(window.location.search);
-    const configString = queryParams.get('config');
-    if (configString) {
-      try {
-        const decodedConfig = atob(configString);
-        const config = JSON.parse(decodedConfig);
-        dispatch(configUpdated(config));
-      } catch (error) {
-        console.error('Invalid config');
+  useEffect(
+    () => {
+      let cancelled = false;
+
+      const fetchConfig = async () => {
+        const configResponse = await fetch('http://localhost:8080/https://gitlab.georgiatech-metz.fr/soehrl/fake-data-config/raw/main/config.json');
+        const json = await configResponse.json();
+        validate(json, ConfigSchema, { throwAll: true });
+        const config = json as Config;
+        
+        if (cancelled) {
+          return;
+        }
+
+        for (const robot of config.robots) {
+          const robotResponse = await fetch(`http://localhost:8080/https://gitlab.georgiatech-metz.fr/soehrl/fake-data-config/raw/main/${robot}`);
+          const json = await robotResponse.json();
+          validate(json, RobotSchema, { throwAll: true });
+          const robotConfig = json as Robot;
+          if (!cancelled) {
+            addRobot(robotConfig);
+          }
+        }
       }
-    }
-  }, []);
 
-  useNonInitialEffect(() => {
-    navigate(`?config=${btoa(JSON.stringify(config))}`);
-  }, [config]);
+      fetchConfig().catch(console.error);
+
+      return () => { cancelled = true };
+    },
+    [addRobot]
+  );
+
+  useEffect(
+    () => {
+      const interval = setInterval(() => updateMessageRates(1), 1000);
+      return () => clearInterval(interval);
+    },
+    [updateMessageRates]
+  );
+
+  // useEffect(() => {
+  //   const queryParams = new URLSearchParams(window.location.search);
+  //   const configString = queryParams.get('config');
+  //   if (configString) {
+  //     try {
+  //       const decodedConfig = atob(configString);
+  //       const config = JSON.parse(decodedConfig);
+  //       dispatch(configUpdated(config));
+  //     } catch (error) {
+  //       console.error('Invalid config');
+  //     }
+  //   }
+  // }, []);
+
+  // useNonInitialEffect(() => {
+  //   navigate(`?config=${btoa(JSON.stringify(config))}`);
+  // }, [config]);
 
   return (
     <ThemeProvider theme={darkMode ? darkTheme: lightTheme}>
@@ -70,7 +112,9 @@ function App() {
             horizontal
           >
             <Viewport />
+            {/*
             <Viewport2D />
+            */}
           </Split>
         </Split>
       </Box>
diff --git a/src/app/MenuBar.tsx b/src/app/MenuBar.tsx
index a3e177504073f51cdfc150ed34e0e5b3278ddd72..7f02bf1ddd34c43fde41d81a84bf32fd9d73b7a7 100644
--- a/src/app/MenuBar.tsx
+++ b/src/app/MenuBar.tsx
@@ -1,9 +1,8 @@
-import { AppBar as MUIAppBar, IconButton, Switch } from '@mui/material';
-import { DarkMode, LightMode, SmartToy as RobotIcon } from '@mui/icons-material';
+import { AppBar as MUIAppBar, Switch } from '@mui/material';
+import { DarkMode, LightMode } from '@mui/icons-material';
 import Grid2 from '@mui/material/Unstable_Grid2';
-import ConnectionsMenu from '../features/connections/ConnectonsMenu';
 import CameraMenu from '../features/camera/CameraMenu';
-import RobotsMenu from '../features/robots/RobotMenu';
+import ConnectionMenu from '../features/connections/ConnectionsMenu';
 
 export interface AppBarProps {
   darkMode: boolean,
@@ -15,8 +14,7 @@ function AppBar(props: AppBarProps) {
     <MUIAppBar position="sticky">
       <Grid2 container>
         <Grid2>
-          <ConnectionsMenu />
-          <RobotsMenu />
+          <ConnectionMenu />
           <CameraMenu />
         </Grid2>
         <Grid2 xs>
diff --git a/src/app/store.ts b/src/app/store.ts
index d3c1495151ae691cf6a9ed0fdcefcec05d29978c..3f2134a167cb523b00b380891b3635e3fec3e2b6 100644
--- a/src/app/store.ts
+++ b/src/app/store.ts
@@ -1,10 +1,12 @@
 import { configureStore } from '@reduxjs/toolkit';
 import configReducer from '../features/config/config-slice';
 import cameraReducer from '../features/camera/camera-slice';
+import connectionsReducer from '../features/connections/rosbridge-connections-slice';
 
 export const store = configureStore({ 
   reducer: {
     config: configReducer,
+    connections: connectionsReducer,
     camera: cameraReducer,
   },
 });
diff --git a/src/common/ROS/geometry_msgs.ts b/src/common/ROS/geometry_msgs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0c79337a44c6af7877bb90b0e9008839ee8598d1
--- /dev/null
+++ b/src/common/ROS/geometry_msgs.ts
@@ -0,0 +1,44 @@
+import std_msgs from './std_msgs';
+
+type geometry_msgs = {
+  Vector3: {
+    x: number
+    y: number
+    z: number
+  }
+
+  Point: {
+    x: number
+    y: number
+    z: number
+  }
+
+  Quaternion: {
+    x: number
+    y: number
+    z: number
+    w: number
+  }
+
+  Transform: {
+    translation: geometry_msgs['Vector3']
+    rotation: geometry_msgs['Quaternion']
+  }
+
+  TransformStamped: {
+    header: std_msgs['Header']
+    transform: geometry_msgs['Transform']
+  }
+
+  Pose: {
+    position: geometry_msgs['Point']
+    orientation: geometry_msgs['Quaternion']
+  }
+
+  PoseStamped: {
+    header: std_msgs['Header']
+    pose: geometry_msgs['Pose']
+  }
+};
+
+export default geometry_msgs;
diff --git a/src/common/ROS/geometry_msgs.tsx b/src/common/ROS/geometry_msgs.tsx
deleted file mode 100644
index f7d4420791384f09fc1513e35c559b14ec7fb8c0..0000000000000000000000000000000000000000
--- a/src/common/ROS/geometry_msgs.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Header } from "./std_msgs";
-
-export interface Vector3 {
-  x: number;
-  y: number;
-  z: number;
-}
-
-export interface Quaternion {
-  x: number;
-  y: number;
-  z: number;
-  w: number;
-}
-
-export interface Transform {
-  translation: Vector3;
-  rotation: Quaternion;
-}
-
-export interface TransformStamped {
-  header: Header;
-  child_frame_id: string;
-  transform: Transform;
-}
-
-export interface tfMessage {
-  transforms: Transform[];
-}
diff --git a/src/common/ROS/index.ts b/src/common/ROS/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7d3d7b3c8596a1bfc9f85fb782888ae2ef6dc65b
--- /dev/null
+++ b/src/common/ROS/index.ts
@@ -0,0 +1,11 @@
+import std_msgs from './std_msgs';
+import geometry_msgs from './geometry_msgs';
+import sensor_msgs from './sensor_msgs';
+import mesh_msgs from './mesh_msgs';
+
+export type Messages = {
+  std_msgs: std_msgs;
+  geometry_msgs: geometry_msgs;
+  sensor_msgs: sensor_msgs;
+  mesh_msgs: mesh_msgs;
+}
diff --git a/src/common/ROS/mesh_msgs.ts b/src/common/ROS/mesh_msgs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fb558d9e72ed214f4e27167604933a2736e11bb1
--- /dev/null
+++ b/src/common/ROS/mesh_msgs.ts
@@ -0,0 +1,22 @@
+import geometry_msgs from "./geometry_msgs";
+import std_msgs from "./std_msgs";
+
+type mesh_msgs = {
+  MeshTriangleIndices: {
+    vertex_indices: [number, number, number]
+  }
+
+  MeshGeometry: {
+    vertices: geometry_msgs['Vector3'][]
+    vertex_normals: geometry_msgs['Vector3'][]
+    faces: mesh_msgs['MeshTriangleIndices'][]
+  }
+
+  MeshGeometryStamped: {
+    header: std_msgs['Header']
+    uuid: string
+    mesh_geometry: mesh_msgs['MeshGeometry']
+  }
+}
+
+export default mesh_msgs;
diff --git a/src/common/ROS/sensor_msgs.ts b/src/common/ROS/sensor_msgs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..238c831bd4576c80598f665b18b26f8b459f3063
--- /dev/null
+++ b/src/common/ROS/sensor_msgs.ts
@@ -0,0 +1,100 @@
+import std_msgs from "./std_msgs";
+
+enum PowerSupplyStatus {
+  Unknown = 0,
+  Charging = 1,
+  Discharging = 2,
+  NotCharging = 3,
+  Full = 4,
+}
+
+enum PowerSupplyHealth {
+  Unknown = 0,
+  Good = 1,
+  Overheat = 2,
+  Dead = 3,
+  Overvoltage = 4,
+  UnspecFailure = 5,
+  Cold = 6,
+  WatchdogTimerExpire = 7,
+  SafetyTimerExpire = 8,
+}
+
+enum PowerSupplyTechnology {
+  Unknown = 0,
+  NiMH = 1,
+  Lion = 2,
+  LiPo = 3,
+  LiFE = 4,
+  NiCd = 5,
+  LiMn = 6,
+}
+
+enum PointFieldDataType {
+  Int8 = 1,
+  UInt8 = 2,
+  Int16 = 3,
+  UInt16 = 4,
+  Int32 = 5,
+  UInt32 = 6,
+  Float32 = 7,
+  Float64 = 8,
+}
+
+type sensor_msgs = {
+  BatteryState: {
+    header: std_msgs['Header']
+    voltage: number
+    temperature: number
+    current: number
+    charge: number
+    capacity: number
+    design_capacity: number
+    percentage: number
+
+    power_supply_status: PowerSupplyStatus
+    power_supply_health: PowerSupplyHealth
+    power_supply_technology: PowerSupplyTechnology
+    present: boolean
+
+    cell_voltage: number[]
+    cell_temperature: number[]
+
+    location: string
+    serial_number: string
+  }
+
+  PointField: {
+    name: string
+    offset: number
+    datatype: PointFieldDataType
+    count: number
+  }
+
+  PointCloud2: {
+    width: number
+    height: number
+    fields: sensor_msgs['PointField'][]
+    is_bigendian: boolean
+    point_step: number
+    row_step: number
+    data: string
+    is_dense: boolean
+  }
+
+  CompressedImage: {
+    header: std_msgs['Header']
+    format: 'jpeg' | 'png'
+    data: string
+  }
+
+  Image: {
+    width: number
+    height: number
+    encoding: 'rgb8' | 'mono8' | '16UC1' | 'bgr8' | string // There are more, but these are the supported ones
+    step: number
+    data: string
+  }
+}
+
+export default sensor_msgs;
diff --git a/src/common/ROS/std_msgs.ts b/src/common/ROS/std_msgs.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7f413049b59badff82e1905e6c44c0de35ce7c0f
--- /dev/null
+++ b/src/common/ROS/std_msgs.ts
@@ -0,0 +1,12 @@
+type std_msgs = {
+  Header: {
+    seq: number;
+    time: {
+      sec: number;
+      nsec: number;
+    };
+    frame_id: string;
+  };
+}
+
+export default std_msgs;
diff --git a/src/common/ROS/std_msgs.tsx b/src/common/ROS/std_msgs.tsx
deleted file mode 100644
index da7d8f76c1cf6392a277145b89ff90c02f7880e8..0000000000000000000000000000000000000000
--- a/src/common/ROS/std_msgs.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-export interface Header {
-}
diff --git a/src/common/store.ts b/src/common/store.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9bd3779a6c72cf70a92de1c92d38b67a2c9e0f4a
--- /dev/null
+++ b/src/common/store.ts
@@ -0,0 +1,340 @@
+import { useCallback, useEffect } from 'react';
+import { Ros, Topic } from 'roslib';
+import create from 'zustand';
+import { Robot } from '../schemas/Robot.schema';
+import { Messages } from "./ROS";
+
+export interface ROSTopicState {
+  topic: Topic;
+  name: string;
+  type: string;
+  subscriberCount: number;
+  updateCallback: (value: any) => void;
+  value: any;
+  messageCount: number;
+  messageRate: number;
+}
+
+export interface RosbridgeConnectionState {
+  uri: string;
+  bsonMode: boolean;
+  status: 'establishing connection' | 'connected' | 'disconnected';
+  rosbridge: Ros;
+  topics: {[topic: string]: ROSTopicState | number};
+}
+
+export interface ConfigState {
+  connections: {[uri: string]: RosbridgeConnectionState};
+  robots: {[name: string]: Robot};
+
+  connect: (uri: string, bsonMode: boolean) => void;
+  updateTopics: (uri: string) => void;
+
+  addRobot: (robot: Robot) => void;
+
+  subscribe: (uri: string, topic: string) => void;
+  unsubscribe: (uri: string, topic: string) => void;
+  updateMessageRates: (elapsedTime: number) => void;
+}
+
+export const useStore = create<ConfigState>((set, get) => {
+
+  const createSubscriptionHandler = (uri: string, topic: string) => {
+    return (value: any) => {
+      // console.info(`Updating topic ${topic} of ${uri} to ${JSON.stringify(value)}`);
+      set(state => {
+        const topicState = state.connections[uri].topics[topic];
+        if (typeof topicState === 'number') {
+          console.error(`Recevied a message for a topic not subscribed to!`);
+          return {};
+        // } else {
+        //   console.info(`Updating topic ${topic} of ${uri} to ${JSON.stringify(value)}`);
+        }
+
+        return {
+          connections: {
+            ...state.connections,
+            [uri]: {
+              ...state.connections[uri],
+              topics: {
+                ...state.connections[uri].topics,
+                [topic]: {
+                  ...topicState,
+                  messageCount: topicState.messageCount + 1,
+                  value,
+                }
+              }
+            }
+          }
+        }
+      });
+    }
+  };
+
+  return {
+    robots: {},
+    connections: {},
+    
+    connect: (uri, bsonMode) => {
+      if (!(uri in get().connections)) {
+        console.log(`Connecting to ${uri}`);
+        const rosbridge = new Ros({ url: uri });
+
+        set(state => ({
+          connections: {
+            ...state.connections,
+            [uri]: {
+              uri,
+              bsonMode,
+              status: 'establishing connection',
+              topics: {},
+              rosbridge,
+            }
+          }
+        }));
+
+        rosbridge.on('connection', _ => {
+          set(state => ({
+            connections: {
+              ...state.connections,
+              [uri]: {
+                ...state.connections[uri],
+                status: 'connected',
+              }
+            }
+          }));
+        });
+
+        rosbridge.on('close', _ => {
+          set(state => ({
+            connections: {
+              ...state.connections,
+              [uri]: {
+                ...state.connections[uri],
+                status: 'disconnected',
+              }
+            }
+          }));
+        });
+
+        get().updateTopics(uri);
+      }
+    },
+
+    updateTopics: (uri: string) => {
+      const connection = get().connections[uri];
+      if (!connection) {
+        console.error(`No connection to ${uri} exists`);
+        return;
+      }
+
+      connection.rosbridge.getTopics(({ topics, types }) => {
+        if (topics.length !== types.length) {
+          console.error(`Invalid response from rosbridge`);
+          return;
+        }
+
+        set(state => {
+          const oldTopics = state.connections[uri].topics;
+          const connectionTopics: {[topic: string]: ROSTopicState} = {};
+
+          for (let i = 0; i < topics.length; ++i) {
+            const oldTopic = oldTopics[topics[i]];
+            if (typeof oldTopic === 'number' || typeof oldTopic === 'undefined') {
+              const handler = createSubscriptionHandler(uri, topics[i]);
+              const topicState = {
+                name: topics[i],
+                type: types[i],
+                value: null,
+                subscriberCount: oldTopic || 0,
+                topic: new Topic({
+                  ros: connection.rosbridge,
+                  name: topics[i],
+                  messageType: types[i],
+                }),
+                updateCallback: handler,
+                messageCount: 0,
+                messageRate: 0,
+              };
+              console.log(`Discovered ${topicState.name} on ${uri} (${oldTopic})`);
+              if (oldTopic > 0) {
+                console.log(`Subscribe to ${topicState.name} on ${uri}`);
+                topicState.topic.subscribe(handler);
+              }
+              connectionTopics[topics[i]] = topicState;
+            } else {
+              console.warn(`Not implemented yet! ${topics[i]}`);
+            }
+          }
+
+          return {
+            connections: {
+              ...state.connections,
+              [uri]: {
+                ...state.connections[uri],
+                topics: connectionTopics,
+              }
+            }
+          };
+        });
+      });
+    },
+
+    addRobot: (robot: Robot) => {
+      if (!(robot.name in get().robots)) {
+        if (!(robot.rosbridge.uri in get().connections)) {
+          get().connect(robot.rosbridge.uri, robot.rosbridge.bsonMode || false);
+        }
+        set(state => ({ robots: {
+          ...state.robots,
+          [robot.name]: structuredClone(robot),
+        }}));
+      }
+    },
+
+    subscribe: (uri: string, topicName: string) => {
+      const connection = get().connections[uri];
+      if (!connection) {
+        console.error(`Connection with uri ${uri} not found`);
+        return;
+      }
+
+      const topic = connection.topics[topicName];
+      if (!topic) {
+        console.info(`Registered ${topicName} on ${uri} for subscription`);
+        set(state => ({
+          connections: {
+            ...state.connections,
+            [uri]: {
+              ...state.connections[uri],
+              topics: {
+                ...state.connections[uri].topics,
+                [topicName]: 1,
+              }
+            }
+          }
+        }));
+      } else if (typeof topic === 'number') {
+        set(state => ({
+          connections: {
+            ...state.connections,
+            [uri]: {
+              ...state.connections[uri],
+              topics: {
+                ...state.connections[uri].topics,
+                [topicName]: topic + 1,
+              }
+            }
+          }
+        }));
+      } else {
+        if (topic.subscriberCount === 0) {
+          console.log(`Subscribe to ${topicName} on ${uri}`);
+          topic.topic.subscribe(topic.updateCallback);
+        }
+        topic.subscriberCount++;
+        console.log(`Increased subscribers of ${topicName} on ${uri} to ${topic.subscriberCount}`);
+
+        set(state => ({
+          connections: {
+            [uri]: {
+              ...state.connections[uri],
+              topics: {
+                ...state.connections[uri].topics,
+                [topicName]: topic,
+              }
+            }
+          }
+        }));
+      }
+    },
+
+    unsubscribe: (uri: string, topicName: string) => {
+      const connection = get().connections[uri];
+      if (!connection) {
+        console.error(`Connection with uri ${uri} not found`);
+        return;
+      }
+
+      const topic = connection.topics[topicName];
+
+      if (typeof topic !== 'object') {
+        console.error(`Trying to unsubscribe from a topic not subscribed to!`);
+        return;
+      }
+
+      topic.subscriberCount--;
+      console.log(`Decreased subscribers of ${topicName} on ${uri} to ${topic.subscriberCount}`);
+      if (topic.subscriberCount === 0) {
+        console.log(`Unsubscribe from ${topicName} on ${uri}`);
+        topic.topic.unsubscribe(topic.updateCallback);
+      }
+      set(state => ({
+        connections: {
+          [uri]: {
+            ...state.connections[uri],
+            topics: {
+              ...state.connections[uri].topics,
+              [topicName]: topic,
+            }
+          }
+        }
+      }));
+    },
+
+    updateMessageRates: (elapsedTime: number) => {
+      set(state => {
+        for (const connection of Object.values(state.connections)) {
+          for (const topic of Object.values(connection.topics)) {
+            if (typeof topic === 'object') {
+              if (topic.messageCount > 0) {
+                console.log(`Topic ${topic.name} received ${topic.messageCount} messages in the last ${elapsedTime} seconds`);
+              }
+              topic.messageRate = topic.messageCount / elapsedTime;
+              topic.messageCount = 0;
+            }
+          }
+        }
+        return {
+          connections: { ...state.connections }
+        }
+      });
+    }
+  }
+});
+
+export function useConnection(uri?: string) {
+  return useStore(
+    useCallback(
+      state => uri ? state.connections[uri] : undefined,
+      [uri]
+    )
+  );
+}
+
+export function useTopic<
+  Package extends string & keyof Messages,
+  Message extends string & keyof Messages[Package]
+>(uri?: string, topic?: string, _?: `${Package}/${Message}`) {
+  const subscribe = useStore(useCallback(state => state.subscribe, []));
+  const unsubscribe = useStore(useCallback(state => state.unsubscribe, []));
+
+  useEffect(
+    () => {
+      if (uri && topic) {
+        subscribe(uri, topic);
+
+        return () => unsubscribe(uri, topic);
+      }
+    },
+    [subscribe, unsubscribe, uri, topic]
+  );
+
+
+  const connectionTopic = useStore(useCallback(state => uri && topic ? state.connections[uri]?.topics[topic]: undefined, [uri, topic]));
+  if (typeof connectionTopic === 'number' || typeof connectionTopic === 'undefined') {
+    return undefined;
+  } else {
+    return connectionTopic.value as Messages[Package][Message] | undefined;
+  }
+}
diff --git a/src/config-schemas b/src/config-schemas
new file mode 160000
index 0000000000000000000000000000000000000000..d0bde3353b2f5d718d2ff0562d5ddcca6fe98987
--- /dev/null
+++ b/src/config-schemas
@@ -0,0 +1 @@
+Subproject commit d0bde3353b2f5d718d2ff0562d5ddcca6fe98987
diff --git a/src/features/config/config-hooks.ts b/src/features/config/config-hooks.ts
new file mode 100644
index 0000000000000000000000000000000000000000..09ca0da71ab50f4da495d7ea8c09c0d9378cb825
--- /dev/null
+++ b/src/features/config/config-hooks.ts
@@ -0,0 +1,5 @@
+import { useAppSelector } from "../../app/hooks";
+
+export function useRobot(name: string) {
+  return useAppSelector(state => state.config.robots.find(robot => robot.name === name));
+}
diff --git a/src/features/config/config-slice.ts b/src/features/config/config-slice.ts
index 6f25008f4a22859e70a2a8f3f60674e4cb537551..a97cc4f4d7d1663b15fedb770d5b4fce64f1bbbe 100644
--- a/src/features/config/config-slice.ts
+++ b/src/features/config/config-slice.ts
@@ -1,57 +1,35 @@
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-
-export interface ConnectionConfig {
-  url: string;
-}
-
-export type RobotType = "Crawler" | "Airborne Drone" | "Underwater Drone";
-
-export interface RobotConfig {
-  connection: string;
-  prefix: string;
-  poseTopic?: string;
-  type: RobotType;
-}
+import { Robot } from '../../schemas/Robot.schema';
 
 interface ConfigState {
-  connections: {[key: string]: ConnectionConfig};
-  robots: {[key: string]: RobotConfig};
-  shipMesh?: string;
+  robots: Robot[];
 }
 
 const initialState: ConfigState = {
-  connections: {},
-  robots: {},
+  robots: [],
 }
 
 const configSlice = createSlice({
   name: 'config',
   initialState,
   reducers: {
-    configUpdated(_state, action: PayloadAction<ConfigState>) {
+    updateConfig(_state, action: PayloadAction<ConfigState>) {
       return action.payload
     },
-    connectionAdded(state, action: PayloadAction<{name: string, config: ConnectionConfig}>) {
-      if (!(action.payload.name in state.connections)) {
-        state.connections[action.payload.name] = action.payload.config;
+    addRobot(state, action: PayloadAction<Robot>) {
+      const robotToAdd = action.payload;
+      if (!state.robots.find(robot => robot.name === robotToAdd.name)) {
+        state.robots.push(robotToAdd);
       }
     },
-    connectionRemoved(state, action: PayloadAction<string>) {
-      delete state.connections[action.payload];
-    },
-    robotAdded(state, action: PayloadAction<{name: string, config: RobotConfig}>) {
-      if (!(action.payload.name in state.robots)) {
-        state.robots[action.payload.name] = action.payload.config;
+    removeRobot(state, action: PayloadAction<string>) {
+      const robotIndex = state.robots.findIndex(robot => robot.name === action.payload);
+      if (robotIndex !== -1) {
+        state.robots.splice(robotIndex, 1);
       }
-    },
-    robotRemoved(state, action: PayloadAction<string>) {
-      delete state.robots[action.payload];
-    },
-    shipMeshSet(state, action: PayloadAction<string|undefined>) {
-      state.shipMesh = action.payload;
-    },
+    }
   }
 });
 
-export const { connectionAdded, connectionRemoved, configUpdated, robotAdded, robotRemoved, shipMeshSet } = configSlice.actions;
+export const { addRobot, removeRobot, updateConfig } = configSlice.actions;
 export default configSlice.reducer;
diff --git a/src/features/connections/ConnectionInfoDialog.tsx b/src/features/connections/ConnectionInfoDialog.tsx
index b0eb03f95d6635dd7d4bc6e2b9632d681fabb772..58f74cf2a16d2f3f955dc828d0baf6b3cba235f0 100644
--- a/src/features/connections/ConnectionInfoDialog.tsx
+++ b/src/features/connections/ConnectionInfoDialog.tsx
@@ -3,14 +3,31 @@ import { Accordion, AccordionDetails, AccordionSummary, CircularProgress, Link,
 import { useEffect, useState } from 'react';
 import { Param, Ros } from 'roslib';
 import For from '../../common/For';
-import { Topic, useConnection, useTopic } from './RosbridgeConnections';
 import Show from '../../common/Show';
 import Dialog from '../../common/Dialog';
+import { ROSTopicState, useConnection, useTopic } from '../../common/store';
+
+interface TopicMessageProps {
+  uri: string;
+  topic: ROSTopicState;
+}
+
+const TopicMessage = (props: TopicMessageProps) => {
+  const message = useTopic(props.uri, props.topic.name);
+
+  return (
+    <pre
+      style={{ margin: 0 }}
+    >
+    { JSON.stringify(message, undefined, 4) }
+    </pre>
+  );
+};
 
 interface TopicInfoProps {
-  topic: Topic;
+  uri: string;
+  topic: ROSTopicState;
   rosDistro?: string|null;
-  ros: Ros,
   searchQuery?: string,
 }
 
@@ -19,9 +36,8 @@ const TopicInfo = (props: TopicInfoProps) => {
     const [pkg, message] = props.topic.type.split("/");
     return `http://docs.ros.org/en/${props.rosDistro || "melodic"}/api/${pkg}/html/msg/${message}.html`;
   };
-  
-  const [shouldSubscribe, setShouldSubscribe] = useState(false);
-  const message = useTopic(props.ros, props.topic.name, props.topic.type, shouldSubscribe);
+
+  const [showMessage, setShowMessage] = useState(false);
 
   return (
     <Show
@@ -32,7 +48,7 @@ const TopicInfo = (props: TopicInfoProps) => {
       }
     >
       <Accordion
-        onChange={(_, expanded) => setShouldSubscribe(expanded)}
+        onChange={(_, expanded) => setShowMessage(expanded)}
       >
         <AccordionSummary
           expandIcon={<ExpandMore />}
@@ -46,10 +62,12 @@ const TopicInfo = (props: TopicInfoProps) => {
           >
             { props.topic.type }
           </Link>
+          &emsp;
+          ({props.topic.messageRate}Hz, {props.topic.subscriberCount} Subscriber)
         </AccordionSummary>
         <AccordionDetails>
-          <Show when={message} fallback={<CircularProgress />}>
-            <pre style={{ margin: 0 }}>{ JSON.stringify(message, undefined, 2) }</pre>
+          <Show when={showMessage}>
+            <TopicMessage uri={props.uri} topic={props.topic} />
           </Show>
         </AccordionDetails>
       </Accordion>
@@ -58,48 +76,48 @@ const TopicInfo = (props: TopicInfoProps) => {
 }
 
 export interface ConnectionInfoDialogProps {
-  connection: string;
-  open?: boolean;
-  close: () => void;
+  connection?: string;
+  onClose: () => void;
 }
 
 const ConnectionInfoDialog = (props: ConnectionInfoDialogProps) => {
   const connection = useConnection(props.connection);
-  const [rosdistro, setRosdistro] = useState<string|null>(null);
-  const [topics, setTopics] = useState<undefined|null|Topic[]>(undefined);
-  const [topicSearchQuery, setTopicSearchQuery] = useState<string|undefined>();
+  // const [rosdistro, setRosdistro] = useState<string|null>(null);
+  // const [topics, setTopics] = useState<undefined|null|Topic[]>(undefined);
+  // const [topicSearchQuery, setTopicSearchQuery] = useState<string|undefined>();
 
-  useEffect(() => {
-    if (props.open && connection) {
-      const rosdistro = new Param({ ros: connection, name: '/rosdistro' });
-      rosdistro.get(rosdistro => setRosdistro(rosdistro));
+  // useEffect(() => {
+  //   if (props.open && connection) {
+  //     const rosdistro = new Param({ ros: connection, name: '/rosdistro' });
+  //     rosdistro.get(rosdistro => setRosdistro(rosdistro));
 
-      connection.getTopics(
-        topics => {
-          const topicsArray: Topic[] = [];
-          for (let i = 0; i < topics.topics.length; ++i) {
-            topicsArray.push({
-              name: topics.topics[i],
-              type: topics.types[i],
-            });
-          }
-          setTopics(topicsArray);
-        },
-        () => {
-          setTopics(null);
-        }
-      );
-   }
-  }, [props.open, connection]);
+  //     connection.getTopics(
+  //       topics => {
+  //         const topicsArray: Topic[] = [];
+  //         for (let i = 0; i < topics.topics.length; ++i) {
+  //           topicsArray.push({
+  //             name: topics.topics[i],
+  //             type: topics.types[i],
+  //           });
+  //         }
+  //         setTopics(topicsArray);
+  //       },
+  //       () => {
+  //         setTopics(null);
+  //       }
+  //     );
+  //  }
+  // }, [props.open, connection]);
 
   return (
     <Dialog
-      open={props.open || false}
+      open={!!props.connection}
       title={props.connection}
-      close={props.close}
+      close={props.onClose}
       width="80%"
       height="80%"
     >
+    {/*
       <h2>ROS distribution</h2>
       <Show
         when={rosdistro}
@@ -125,26 +143,29 @@ const ConnectionInfoDialog = (props: ConnectionInfoDialogProps) => {
           }}
         />
       </h2>
-      <Show
-        when={topics}
-        fallback={ typeof topics === 'undefined' ? <CircularProgress /> : <p>An error occured while querying the topics.</p>}
-      >
+    */}
       {
-      topics =>
-        <For each={topics}>
-        {
-        topic =>
-          <TopicInfo
-            key={`${props.connection}${topic.name}`}
-            topic={topic}
-            rosDistro={rosdistro}
-            ros={connection}
-            searchQuery={topicSearchQuery}
-          />
-        }
-        </For>
+        connection ?
+          Object
+            .keys(connection.topics)
+            .map(name => ({name, topic: connection.topics[name]}))
+            .map(({name, topic}) =>
+            typeof topic === 'number' ?
+              <p
+                key={`${props.connection}${name}`}
+              >
+                {`${name}: ${topic}`}
+              </p>
+            :
+              <TopicInfo
+                key={`${props.connection}${name}`}
+                uri={props.connection || ""}
+                topic={topic}
+              />
+          )
+        :
+          null
       }
-      </Show>
     </Dialog>
   );
 };
diff --git a/src/features/connections/ConnectionsMenu.tsx b/src/features/connections/ConnectionsMenu.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..021bc0bb5b17fb26701ed9c57e380578f94465c9
--- /dev/null
+++ b/src/features/connections/ConnectionsMenu.tsx
@@ -0,0 +1,53 @@
+
+import PopoverListButton from "../../common/PopoverListButton";
+import { useAppDispatch, useAppSelector } from '../../app/hooks';
+import { RadioButtonChecked, RadioButtonUnchecked, Power, PowerOff } from '@mui/icons-material';
+import { List, ListItem, ListItemButton, ListItemIcon, ListItemText } from "@mui/material";
+import Show from "../../common/Show";
+import { useStore } from '../../common/store';
+import { useState } from "react";
+import ConnectionInfoDialog from "./ConnectionInfoDialog";
+
+
+const ConnectionMenu = () => {
+  const connections = useStore(state => state.connections);
+  const [selectedConnection, selectConnection] = useState<string>();
+
+  return (
+    <>
+      <ConnectionInfoDialog
+        connection={selectedConnection}
+        onClose={() => { selectConnection(undefined) }}
+      />
+      <PopoverListButton
+        icon={ Power }
+        title="Connections"
+      >
+      {
+        Object.keys(connections).map(uri => connections[uri]).map(connection => 
+          <ListItem
+            key={connection.uri}
+          >
+            <ListItemButton
+              onClick={() => selectConnection(connection.uri)}
+            >
+              <ListItemIcon>
+              {
+                connection.status === 'disconnected' ?
+                  <PowerOff color="error" /> : 
+                  <Power color={connection.status === 'connected' ? "success" : "warning"} />
+              }
+              </ListItemIcon>
+              <ListItemText>
+                { connection.uri }
+              </ListItemText>
+            </ListItemButton>
+          </ListItem>
+        )
+      }
+      </PopoverListButton>
+    </>
+  );
+};
+
+export default ConnectionMenu;
diff --git a/src/features/connections/ConnectonsMenu.tsx b/src/features/connections/ConnectonsMenu.tsx
deleted file mode 100644
index 4790794713c2c2e294861fee5834aca422901fb0..0000000000000000000000000000000000000000
--- a/src/features/connections/ConnectonsMenu.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import { Divider, IconButton, ListItem, ListItemButton } from "@mui/material";
-import PopoverListButton from "../../common/PopoverListButton";
-import { Power as ConnectionIcon, Delete } from '@mui/icons-material';
-import NewConnectionDialog from "./NewConnectionDialog";
-import { Fragment, useEffect, useState } from "react";
-import { useAppDispatch, useAppSelector } from '../../app/hooks';
-import For from "../../common/For";
-import Show from "../../common/Show";
-import { ConnectionConfig, connectionRemoved } from "../config/config-slice";
-import { useConnection } from "./RosbridgeConnections";
-import ConnectionInfoDialog from "./ConnectionInfoDialog";
-
-interface ConnectionsMenuEntryProps {
- name: string;
- config: ConnectionConfig;
-}
-
-const ConnectionsMenuEntry = (props: ConnectionsMenuEntryProps) => {
-  const dispatch = useAppDispatch();
-  const connection = useConnection(props.name);
-  const connectionConfig = useAppSelector(state => state.config.connections[props.name]);
-  const [connected, setConnected] = useState(connection ? connection.isConnected : false);
-  const [connectionInfoOpen, setConnectionInfoOpen] = useState(false);
-
-  useEffect(() => {
-    const connectListener = () => setConnected(true);
-    const disconnectListener = () => setConnected(false);
-    if (connection) {
-      connection.addListener('connection', connectListener);
-      connection.addListener('close', disconnectListener);
-    }
-
-    return () => {
-      if (connection) {
-        connection.removeListener('connection', connectListener);
-        connection.removeListener('close', disconnectListener);
-      }
-    }
-  }, [connection]);
-
-  return (
-    <ListItem>
-      <IconButton
-        onClick={() => connected ? connection.close() : connection.connect(connectionConfig.url)}
-        color={connected ? "success" : connection ? "error" : "warning"}
-        disabled={!connection}
-      >
-        <ConnectionIcon />
-      </IconButton>
-      <ListItemButton onClick={() => setConnectionInfoOpen(true)}>
-        {props.name}
-      </ListItemButton>
-      <IconButton
-        onClick={() => dispatch(connectionRemoved(props.name))}
-      >
-        <Delete />
-      </IconButton>
-      <ConnectionInfoDialog
-        connection={props.name}
-        open={connectionInfoOpen}
-        close={() => setConnectionInfoOpen(false)}
-      />
-    </ListItem>
-  );
-};
-
-const ConnectionsMenu = () => {
-  const connections = useAppSelector(state => state.config.connections);
-  const [newConnection, setNewConnection] = useState(false);
-
-  return (
-    <Fragment>
-      <NewConnectionDialog
-        open={newConnection}
-        close={() => setNewConnection(false)}
-      />
-      <PopoverListButton
-        icon={ConnectionIcon}
-        title="Connections"
-      >
-        <For each={Object.keys(connections).map(name => ({ name, config: connections[name] }))}>
-          {
-          connection => <ConnectionsMenuEntry key={connection.name} {...connection} />
-          }
-        </For>
-        <Show when={Object.keys(connections).length > 0}>
-          <Divider />
-        </Show>
-        <ListItem>
-          <ListItemButton onClick={() => setNewConnection(true)}>Add Connection</ListItemButton>
-        </ListItem>
-      </PopoverListButton>
-    </Fragment>
-  );
-};
-
-export default ConnectionsMenu;
diff --git a/src/features/connections/NewConnectionDialog.tsx b/src/features/connections/NewConnectionDialog.tsx
deleted file mode 100644
index fd1e6e07627a8df28ab9c3b976f34a1c24f60eb3..0000000000000000000000000000000000000000
--- a/src/features/connections/NewConnectionDialog.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import Button from '@mui/material/Button';
-import FormControl from '@mui/material/FormControl';
-import TextField from '@mui/material/TextField';
-import { useState } from 'react';
-import Dialog from '../../common/Dialog';
-import { useAppSelector, useAppDispatch } from '../../app/hooks';
-import { connectionAdded } from '../config/config-slice';
-
-export interface NewConnectionDialogProps {
-  open?: boolean;
-  close: () => void;
-}
-
-const NewConnectionDialog = (props: NewConnectionDialogProps) => {
-  const connections = useAppSelector(state => state.config.connections);
-  const dispatch = useAppDispatch();
-  const [name, setName] = useState("New Connection");
-  const [url, setURL] = useState("ws://localhost:9090");
-
-  return (
-    <Dialog
-      open={props.open || false}
-      title="Connection Details"
-      close={props.close}
-    >
-      <FormControl>
-        <TextField
-          label="Name"
-          variant="standard"
-          value={name}
-          error={name in connections}
-          onChange={event => setName(event.target.value)}
-        />
-        <TextField
-          label="URL"
-          variant="standard"
-          value={url}
-          onChange={event => setURL(event.target.value)}
-        />
-        <Button
-          variant="text"
-          disabled={name in connections}
-          onClick={() => {
-            dispatch(connectionAdded({ name, config: { url } }));
-            props.close();
-          }}
-        >
-          Connect
-        </Button>
-      </FormControl>
-    </Dialog>
-  );
-};
-
-export default NewConnectionDialog;
diff --git a/src/features/connections/RosbridgeConnections.tsx b/src/features/connections/RosbridgeConnections.tsx
deleted file mode 100644
index 42be54fbd7ca48267df8459492fd43ccaf7b9cae..0000000000000000000000000000000000000000
--- a/src/features/connections/RosbridgeConnections.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { createContext, PropsWithChildren, useContext, useEffect, useState } from "react";
-import { Message, Ros, Topic as ROSLibTopic } from "roslib";
-import { useAppSelector } from '../../app/hooks';
-
-export interface Topic {
-  name: string;
-  type: string;
-}
-
-export type RosbridgeConnectionMap = {[key: string]: Ros};
-const RosbridgeConnectionsContext = createContext<RosbridgeConnectionMap>({});
-
-const RosbridgeConnections = (props: PropsWithChildren) => {
-  const connectionConfigs = useAppSelector(state => state.config.connections);
-  const [connections, setConnections] = useState<RosbridgeConnectionMap>({});
-
-  useEffect(() => {
-    const newConnections: RosbridgeConnectionMap = {};
-    for (const connectionName of Object.keys(connectionConfigs)) {
-      if (connectionName in connections) {
-        // Check if URL changed
-        newConnections[connectionName] = connections[connectionName];
-      } else {
-        newConnections[connectionName] = new Ros({ url: connectionConfigs[connectionName].url });
-      }
-    }
-    for (const oldConnection of Object.keys(connections)) {
-      if (!(oldConnection in newConnections)) {
-        connections[oldConnection].close();
-      }
-    }
-    setConnections(newConnections);
-  }, [connectionConfigs]);
-
-  return (
-    <RosbridgeConnectionsContext.Provider value={connections}>
-      {
-      props.children
-      }
-    </RosbridgeConnectionsContext.Provider>
-  );
-}
-
-const useConnection = (name: string) => {
-  const connections = useContext(RosbridgeConnectionsContext);
-  return connections[name] || null;
-};
-
-export const useTopics = (connectionName: string) => {
-  const connection = useConnection(connectionName);
-  const [topics, setTopics] = useState<undefined|null|Topic[]>(undefined);
-
-  useEffect(() => {
-    if (connection) {
-      connection.getTopics(
-        topics => {
-          const topicsArray: Topic[] = [];
-          for (let i = 0; i < topics.topics.length; ++i) {
-            topicsArray.push({
-              name: topics.topics[i],
-              type: topics.types[i],
-            });
-          }
-          setTopics(topicsArray);
-        },
-        () => {
-          setTopics(null);
-        }
-      );
-   }
-  }, [connection]);
-
-  return topics;
-}
-
-export const useTopicsOfType = (connectionName: string, types: string|string[]) => {
-  const connection = useConnection(connectionName);
-  const [topics, setTopics] = useState<undefined|null|Topic[]>(undefined);
-
-  if (typeof types === 'string') {
-    types = [types];
-  }
-
-  useEffect(() => {
-    if (connection) {
-      connection.getTopics(
-        topics => {
-          const topicsArray: Topic[] = [];
-          for (let i = 0; i < topics.topics.length; ++i) {
-            if (types.indexOf(topics.types[i]) !== -1) {
-              topicsArray.push({
-                name: topics.topics[i],
-                type: topics.types[i],
-              });
-            }
-          }
-          setTopics(topicsArray);
-        },
-        () => {
-          setTopics(null);
-        }
-      );
-   }
-  }, [connection]);
-
-  return topics;
-}
-
-export function useTopicFromConnection<T = Message>(connection: string, topicName: string, topicType: string, subscribe?: boolean) {
-  const ros = useConnection(connection);
-  const [topic, setTopic] = useState<ROSLibTopic<T>|null>(null);
-  const [message, setMessage] = useState<T|undefined>();
-
-  useEffect(() => {
-    if (ros) {
-      setTopic(new ROSLibTopic<T>({ ros, name: topicName, messageType: topicType }));
-    }
-    return () => setTopic(null);
-  }, [ros, topicName, topicType]);
-  useEffect(() => {
-    if (topic && (typeof subscribe == 'undefined' || subscribe)) {
-      const theTopic = topic;
-      theTopic.subscribe(setMessage);
-      return () => theTopic.unsubscribe();
-    }
-  }, [topic, subscribe]);
-
-  return message;
-}
-
-export function useTopic<T = Message>(ros: Ros, topicName: string, topicType: string, subscribe?: boolean) {
-  const [topic, setTopic] = useState<ROSLibTopic<T>|null>(null);
-  const [message, setMessage] = useState<T|undefined>();
-
-  useEffect(() => {
-    if (ros) {
-      setTopic(new ROSLibTopic<T>({ ros, name: topicName, messageType: topicType }));
-    }
-    return () => setTopic(null);
-  }, [ros, topicName, topicType]);
-  useEffect(() => {
-    if (topic && (typeof subscribe == 'undefined' || subscribe)) {
-      const theTopic = topic;
-      theTopic.subscribe(setMessage);
-      return () => theTopic.unsubscribe();
-    }
-  }, [topic, subscribe]);
-
-  return message;
-}
-
-export default RosbridgeConnections;
-export { RosbridgeConnectionsContext, useConnection };
diff --git a/src/features/connections/connections-selector.ts b/src/features/connections/connections-selector.ts
deleted file mode 100644
index 6d62bdab5ee98317ccc00734e3b9764d467a6677..0000000000000000000000000000000000000000
--- a/src/features/connections/connections-selector.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { createSelector } from '@reduxjs/toolkit';
-import { RootState } from '../../app/store';
-
-// const selectConnection = createSelector<RootState>((state: RootState) => state.config.connections);
-// const selectWebSocketConnectiosn = (state: RootState) =>
-// const selectConnections = (index: number) => createSelector(
-//   (state: RootState) => state.config.connections[index]
-// );
-// const selectConnections = (state: RootState) => state.config.connections;
-// const selectConnection = createSelector(selectConnections, (connections, connectionId: string) => connections[index]);
-// const selectWebSocketConnection = (index: number) => createSelector(selectConnection(index), connection => new WebSocket(connection.url));
-
-// export { selectConnections, selectConnection, selectWebSocketConnection };
diff --git a/src/features/connections/rosbridge-connections-middleware.ts b/src/features/connections/rosbridge-connections-middleware.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7fcfe6105e783e7b6194bec2a403b6f59d03039a
--- /dev/null
+++ b/src/features/connections/rosbridge-connections-middleware.ts
@@ -0,0 +1,16 @@
+import { Middleware, MiddlewareAPI } from '@reduxjs/toolkit';
+import { RootState } from '../../app/store';
+import { connect } from './rosbridge-connections-slice';
+
+const connectionsMiddleware: Middleware<
+  {},
+  RootState
+> = store => {
+
+  return next => action => {
+    if (connect.match(action)) {
+    }
+  };
+}
+
+export default connectionsMiddleware;
diff --git a/src/features/connections/rosbridge-connections-slice.ts b/src/features/connections/rosbridge-connections-slice.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e0c4bac36749ffb024d23db1b75aa5b058dee0f9
--- /dev/null
+++ b/src/features/connections/rosbridge-connections-slice.ts
@@ -0,0 +1,51 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+interface ROSTopicState {
+  name: string;
+  type: string;
+  value: any;
+  subscriberCount: number;
+}
+
+interface RosbridgeConnectionState {
+  uri: string;
+  bsonMode: boolean;
+  status: 'establishing connection' | 'connected' | 'disconnected';
+  topics: ROSTopicState[];
+}
+
+export type RosbridgeConnectionsState = RosbridgeConnectionState[];
+
+const initialState: RosbridgeConnectionsState = [];
+
+const rosbridgeConnectionsSlice = createSlice({
+  name: 'rosbridgeConnection',
+  initialState,
+  reducers: {
+    connect(state, action: PayloadAction<{ uri: string, bsonMode: boolean}>) {
+      const { uri, bsonMode } = action.payload;
+      if (!state.find(connection => connection.uri === uri)) {
+        state.push({
+          uri,
+          bsonMode,
+          status: 'establishing connection',
+          topics: [],
+        });
+      }
+    },
+    disconnect(state, action: PayloadAction<string>) {
+      const uri = action.payload;
+      const index = state.findIndex(connection => connection.uri === uri)
+      if (index !== -1) {
+        state.splice(index, 1);
+      }
+    },
+    subscribe(state, action: PayloadAction<{ uri: string, topic: string }>) {
+    },
+    unsubscribe(state, action: PayloadAction<{ uri: string, topic: string }>) {
+    },
+  }
+});
+
+export const { connect, disconnect, subscribe, unsubscribe } = rosbridgeConnectionsSlice.actions;
+export default rosbridgeConnectionsSlice.reducer;
diff --git a/src/features/images/CompressedImage.tsx b/src/features/images/CompressedImage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..83ebaf3f1c34dbed8fbee0ed3266e7b0733cab28
--- /dev/null
+++ b/src/features/images/CompressedImage.tsx
@@ -0,0 +1,30 @@
+import { CSSProperties } from "react";
+import Show from "../../common/Show";
+import { useTopic } from "../../common/store";
+
+export interface CompressedROSImageProps {
+  connection: string;
+  topic: string;
+  style?: CSSProperties;
+}
+
+function CompressedROSImage(props: CompressedROSImageProps) {
+  const compressedImage = useTopic(props.connection, props.topic, 'sensor_msgs/CompressedImage');
+  // Todo: use direct dom manipulation here!
+
+  return (
+    <Show when={compressedImage} fallback={<>Loading {props.topic}...</>}>
+    {
+    compressedImage =>
+      <img
+        style={props.style}
+        src={`data:image/${compressedImage.format};base64, ${compressedImage.data}`}
+        title={props.topic}
+        alt={props.topic}
+      />
+    }
+    </Show>
+  );
+};
+
+export default CompressedROSImage;
diff --git a/src/features/images/Image.tsx b/src/features/images/Image.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..619f8c5fa71cbb96eed70125d6a8655098a14954
--- /dev/null
+++ b/src/features/images/Image.tsx
@@ -0,0 +1,98 @@
+import { CSSProperties } from "react";
+
+interface RawROSImageProps {
+  connectionName: string;
+  topic: string;
+  style?: CSSProperties;
+}
+
+// function RawROSImage(props: RawROSImageProps) {
+//   const ros = useConnection(props.connectionName);
+//   const image = useTopic<Image>(ros, props.topic, 'sensor_msgs/Image');
+//   const [context, setContext] = useState<CanvasRenderingContext2D|null>(null);
+
+//   let imageData: ImageData;
+
+//   useEffect(() => {
+//     if (image && context) {
+//       if (!imageData || (imageData.width !== image.width || imageData.height !== image.height)) {
+//         imageData = context.createImageData(image.width, image.height);
+//       }
+//       const data = atob(image.data);
+//       switch (image.encoding) {
+//       case "mono8":
+//         for (let y = 0; y < image.height; ++y) {
+//           for (let x = 0; x < image.width; ++x) {
+//             const destinationBaseIndex = (y * image.width + x) * 4;
+//             const sourceBaseIndex = y * image.step + x;
+
+//             for (let i = 0; i < 3; ++i) {
+//               imageData.data[destinationBaseIndex + i] = data.charCodeAt(sourceBaseIndex);
+//             }
+//             imageData.data[destinationBaseIndex + 3] = 255;
+//           }
+//         }
+//         break;
+
+//       case "rgb8":
+//         for (let y = 0; y < image.height; ++y) {
+//           for (let x = 0; x < image.width; ++x) {
+//             const destinationBaseIndex = (y * image.width + x) * 4;
+//             const sourceBaseIndex = y * image.step + x * 3;
+
+//             for (let i = 0; i < 3; ++i) {
+//               imageData.data[destinationBaseIndex + i] = data.charCodeAt(sourceBaseIndex + i);
+//             }
+//             imageData.data[destinationBaseIndex + 3] = 255;
+//           }
+//         }
+//         break;
+
+//       case "bgr8":
+//         for (let y = 0; y < image.height; ++y) {
+//           for (let x = 0; x < image.width; ++x) {
+//             const destinationBaseIndex = (y * image.width + x) * 4;
+//             const sourceBaseIndex = y * image.step + x * 3;
+
+//             for (let i = 0; i < 3; ++i) {
+//               imageData.data[destinationBaseIndex + i] = data.charCodeAt(sourceBaseIndex + 2 - i);
+//             }
+//             imageData.data[destinationBaseIndex + 3] = 255;
+//           }
+//         }
+//         break;
+
+//       case "16UC1":
+//         for (let y = 0; y < image.height; ++y) {
+//           for (let x = 0; x < image.width; ++x) {
+//             const destinationBaseIndex = (y * image.width + x) * 4;
+//             const sourceBaseIndex = y * image.step + x * 2;
+//             const p0 = data.charCodeAt(sourceBaseIndex + 0);
+//             const p1 = data.charCodeAt(sourceBaseIndex + 1);
+//             const value = (p0 << 0 | p1 << 8) / Math.pow(2, 16) * 256;
+
+//             for (let i = 0; i < 3; ++i) {
+//               imageData.data[destinationBaseIndex + i] = value;
+//             }
+//             imageData.data[destinationBaseIndex + 3] = 255;
+//           }
+//         }
+//         break;
+
+//       default:
+//         console.error(`Unsupported encoding: ${image.encoding}`);
+//       }
+//       context.putImageData(imageData, 0, 0);
+//     }
+//   }, [image, context]);
+
+//   return (
+//     <canvas
+//       ref={canvas => setContext(canvas?.getContext("2d") || null)}
+//       width={image?.width}
+//       height={image?.height}
+//       title={props.topic}
+//       style={props.style}
+//     />
+//   );
+// };
diff --git a/src/features/images/ImageCarousel.tsx b/src/features/images/ImageCarousel.tsx
index 02f1b34a962dacfff36dda086042b49efea4a3df..b3888fc9df6f494571f54bb880c417136ebb761e 100644
--- a/src/features/images/ImageCarousel.tsx
+++ b/src/features/images/ImageCarousel.tsx
@@ -1,159 +1,27 @@
 import { KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material';
 import { Box, Button, MobileStepper, Paper, SxProps, Theme } from '@mui/material';
 import { CSSProperties, PropsWithoutRef, useEffect, useState } from 'react';
-import SwipeableViews from 'react-swipeable-views';
-import { useConnection, useTopic, useTopicsOfType } from '../connections/RosbridgeConnections';
 import Show from '../../common/Show';
+import CompressedROSImage from './CompressedImage';
 
-interface Image {
-  width: number;
-  height: number;
-  encoding: "rgb8" | "mono8" | "16UC1" | "bgr8";
-  step: number;
-  data: string;
-}
-
-interface CompressedImage {
-  format: "jpeg" | "png";
-  data: string;
-}
-
-interface RawROSImageProps {
-  connectionName: string;
+export interface ImageCarouselImage {
   topic: string;
-  style?: CSSProperties;
+  name: string;
 }
 
-function RawROSImage(props: RawROSImageProps) {
-  const ros = useConnection(props.connectionName);
-  const image = useTopic<Image>(ros, props.topic, 'sensor_msgs/Image');
-  const [context, setContext] = useState<CanvasRenderingContext2D|null>(null);
-
-  let imageData: ImageData;
-
-  useEffect(() => {
-    if (image && context) {
-      if (!imageData || (imageData.width !== image.width || imageData.height !== image.height)) {
-        imageData = context.createImageData(image.width, image.height);
-      }
-      const data = atob(image.data);
-      switch (image.encoding) {
-      case "mono8":
-        for (let y = 0; y < image.height; ++y) {
-          for (let x = 0; x < image.width; ++x) {
-            const destinationBaseIndex = (y * image.width + x) * 4;
-            const sourceBaseIndex = y * image.step + x;
-
-            for (let i = 0; i < 3; ++i) {
-              imageData.data[destinationBaseIndex + i] = data.charCodeAt(sourceBaseIndex);
-            }
-            imageData.data[destinationBaseIndex + 3] = 255;
-          }
-        }
-        break;
-
-      case "rgb8":
-        for (let y = 0; y < image.height; ++y) {
-          for (let x = 0; x < image.width; ++x) {
-            const destinationBaseIndex = (y * image.width + x) * 4;
-            const sourceBaseIndex = y * image.step + x * 3;
-
-            for (let i = 0; i < 3; ++i) {
-              imageData.data[destinationBaseIndex + i] = data.charCodeAt(sourceBaseIndex + i);
-            }
-            imageData.data[destinationBaseIndex + 3] = 255;
-          }
-        }
-        break;
-
-      case "bgr8":
-        for (let y = 0; y < image.height; ++y) {
-          for (let x = 0; x < image.width; ++x) {
-            const destinationBaseIndex = (y * image.width + x) * 4;
-            const sourceBaseIndex = y * image.step + x * 3;
-
-            for (let i = 0; i < 3; ++i) {
-              imageData.data[destinationBaseIndex + i] = data.charCodeAt(sourceBaseIndex + 2 - i);
-            }
-            imageData.data[destinationBaseIndex + 3] = 255;
-          }
-        }
-        break;
-
-      case "16UC1":
-        for (let y = 0; y < image.height; ++y) {
-          for (let x = 0; x < image.width; ++x) {
-            const destinationBaseIndex = (y * image.width + x) * 4;
-            const sourceBaseIndex = y * image.step + x * 2;
-            const p0 = data.charCodeAt(sourceBaseIndex + 0);
-            const p1 = data.charCodeAt(sourceBaseIndex + 1);
-            const value = (p0 << 0 | p1 << 8) / Math.pow(2, 16) * 256;
-
-            for (let i = 0; i < 3; ++i) {
-              imageData.data[destinationBaseIndex + i] = value;
-            }
-            imageData.data[destinationBaseIndex + 3] = 255;
-          }
-        }
-        break;
-
-      default:
-        console.error(`Unsupported encoding: ${image.encoding}`);
-      }
-      context.putImageData(imageData, 0, 0);
-    }
-  }, [image, context]);
-
-  return (
-    <canvas
-      ref={canvas => setContext(canvas?.getContext("2d") || null)}
-      width={image?.width}
-      height={image?.height}
-      title={props.topic}
-      style={props.style}
-    />
-  );
-};
-
-export interface CompressedROSImageProps {
-  connection: string;
-  topic: string;
-  style?: CSSProperties;
-}
-
-function CompressedROSImage(props: RawROSImageProps) {
-  const ros = useConnection(props.connectionName);
-  const image = useTopic<CompressedImage>(ros, props.topic, 'sensor_msgs/CompressedImage');
-
-  return (
-    <Show when={image} fallback={<>Loading {props.topic}...</>}>
-    {
-    compressedImage =>
-      <img
-        style={props.style}
-        src={`data:image/${compressedImage.format};base64, ${compressedImage.data}`}
-        title={props.topic}
-      />
-    }
-    </Show>
-  );
-};
-
 export interface ImageCarouselProps {
-  connectionName: string;
-  prefix?: string;
+  connection: string;
+  images: ImageCarouselImage[];
   sx?: SxProps<Theme>;
 }
 
 export default function ImageCarousel(props: PropsWithoutRef<ImageCarouselProps>) {
-  const imageTopics = useTopicsOfType(props.connectionName, ['sensor_msgs/Image', 'sensor_msgs/CompressedImage']) || [];
-  const topics = props.prefix ? imageTopics.filter(topic => topic.name.startsWith(props.prefix as string)) : imageTopics;
   const [activeStep, setActiveStep] = useState(0);
 
-  if (topics.length === 0) {
+  if (props.images.length === 0) {
     return null;
   }
-  const topic = topics[activeStep];
+  const image = props.images[activeStep];
 
   return (
     <Box
@@ -167,44 +35,31 @@ export default function ImageCarousel(props: PropsWithoutRef<ImageCarouselProps>
       <Box
         component="div"
         style={{
-          position: "relative",
           flexGrow: 1,
+          display: 'flex',
+          flexDirection: 'column',
+          alignItems: 'center',
         }}
       >
-        {
-          topic.type === 'sensor_msgs/Image' ?
-            <RawROSImage
-              key={topic.name}
-              connectionName={props.connectionName}
-              topic={topic.name}
-              style={{
-                position: "absolute",
-                top: "50%",
-                left: "50%",
-                transform: "translateX(-50%) translateY(-50%)",
-                maxWidth: "100%",
-                maxHeight: "100%",
-              }}
-            /> 
-            : 
-            <CompressedROSImage
-              key={topic.name}
-              connectionName={props.connectionName}
-              topic={topic.name}
-              style={{
-                position: "absolute",
-                top: "50%",
-                left: "50%",
-                transform: "translateX(-50%) translateY(-50%)",
-                maxWidth: "100%",
-                maxHeight: "100%",
-              }}
-            /> 
-        }
+        <CompressedROSImage
+          key={image.topic}
+          connection={props.connection}
+          topic={image.topic}
+          style={{
+            maxWidth: "100%",
+          }}
+        />
+        <Box
+          component="span"
+          style={{
+          }}
+        >
+          {image.name}
+        </Box>
       </Box>
       <MobileStepper
         variant="dots"
-        steps={topics.length}
+        steps={props.images.length}
         position="static"
         activeStep={activeStep}
         sx={{
@@ -217,7 +72,7 @@ export default function ImageCarousel(props: PropsWithoutRef<ImageCarouselProps>
               setActiveStep(step => step + 1);
               event.stopPropagation();
             }}
-            disabled={activeStep === topics.length - 1}
+            disabled={activeStep === props.images.length - 1}
           >
             <KeyboardArrowRight />
           </Button>
diff --git a/src/features/robots/BatteryIcon.tsx b/src/features/robots/BatteryIcon.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..355096906699cb6e98e9631973844b9d20a9865a
--- /dev/null
+++ b/src/features/robots/BatteryIcon.tsx
@@ -0,0 +1,65 @@
+import BatteryUnknownIcon from '@mui/icons-material/BatteryUnknown';
+import Battery20Icon from '@mui/icons-material/Battery20';
+import Battery30Icon from '@mui/icons-material/Battery30';
+import Battery50Icon from '@mui/icons-material/Battery50';
+import Battery60Icon from '@mui/icons-material/Battery60';
+import Battery80Icon from '@mui/icons-material/Battery80';
+import Battery90Icon from '@mui/icons-material/Battery90';
+import BatteryFullIcon from '@mui/icons-material/BatteryFull';
+import BatteryCharging20Icon from '@mui/icons-material/BatteryCharging20';
+import BatteryCharging30Icon from '@mui/icons-material/BatteryCharging30';
+import BatteryCharging50Icon from '@mui/icons-material/BatteryCharging50';
+import BatteryCharging60Icon from '@mui/icons-material/BatteryCharging60';
+import BatteryCharging80Icon from '@mui/icons-material/BatteryCharging80';
+import BatteryCharging90Icon from '@mui/icons-material/BatteryCharging90';
+import BatteryChargingFullIcon from '@mui/icons-material/BatteryChargingFull';
+import BatteryAlertIcon from '@mui/icons-material/BatteryAlert';
+
+interface BatteryIconProps {
+  percentage?: number;
+  isCharging?: boolean;
+}
+
+export default function BatteryIcon(props: BatteryIconProps) {
+  if (typeof props.percentage === 'undefined') {
+    return <BatteryUnknownIcon color="error" />;
+  }
+
+  if (props.percentage < 0) {
+    return  <BatteryAlertIcon color="error" />;
+  } else if (props.percentage < 0.1) {
+    return props.isCharging ?
+      <BatteryCharging20Icon color="warning" /> :
+      <BatteryAlertIcon color="warning" />;
+  } else if (props.percentage < 0.25) {
+    return props.isCharging ?
+      <BatteryCharging20Icon color="success" /> :
+      <Battery20Icon  />;
+  } else if (props.percentage < 0.4) {
+    return props.isCharging ?
+      <BatteryCharging30Icon color="success" /> :
+      <Battery30Icon  />;
+  } else if (props.percentage < 0.55) {
+    return props.isCharging ?
+      <BatteryCharging50Icon color="success" /> :
+      <Battery50Icon  />;
+  } else if (props.percentage < 0.7) {
+    return props.isCharging ?
+      <BatteryCharging60Icon color="success" /> :
+      <Battery60Icon  />;
+  } else if (props.percentage < 0.85) {
+    return props.isCharging ?
+      <BatteryCharging80Icon color="success" /> :
+      <Battery80Icon  />;
+  } else if (props.percentage < 0.95) {
+    return props.isCharging ?
+      <BatteryCharging90Icon color="success" /> :
+      <Battery90Icon  />;
+  } else if (props.percentage <= 1.0) {
+    return props.isCharging ?
+      <BatteryChargingFullIcon color="success" /> :
+      <BatteryFullIcon  />;
+  } else { //props.percentage > 1
+    return  <BatteryAlertIcon color="error" />;
+  }
+}
diff --git a/src/features/robots/RobotDetailsDialog.tsx b/src/features/robots/RobotDetailsDialog.tsx
index 831514d52afa60c43807b04948cefbda62ad2cee..18d25acb0f8fc20f280b785e21ef6e06f2337ac8 100644
--- a/src/features/robots/RobotDetailsDialog.tsx
+++ b/src/features/robots/RobotDetailsDialog.tsx
@@ -1,58 +1,20 @@
 import Dialog from '../../common/Dialog';
-import { useAppSelector } from '../../app/hooks';
-import ImageCarousel from '../images/ImageCarousel';
 import { Split } from '@geoffcox/react-splitter';
-import { Box, Button, TextField, useTheme } from '@mui/material';
+import { Box, Button, useTheme } from '@mui/material';
 import Viewport from '../viewport/Viewport';
-import { useConnection, useTopic, useTopicFromConnection } from '../connections/RosbridgeConnections';
-import RobotDialog from './RobotDialog';
 import { useState } from 'react';
-
-enum DiagnosticStatusLevel {
-  OK = 0,
-  WARN = 1,
-  ERROR = 2,
-  STALE = 3,
-}
+import { useRobot } from '../config/config-hooks';
+import { useTopic } from '../../common/store';
 
 export interface RobotDetailsDialogProps {
   robot: string;
   close: () => void;
-  message?: string;
-  messageLevel?: DiagnosticStatusLevel;
-  batteryPercentage?: number;
-  clearMessage?: () => void;
-}
-
-interface Pose {
- position: {x: number, y: number, z: number };
- orientation: {w: number, x: number, y: number, z: number }
-}
-
-interface PoseStamped {
-  pose: Pose;
 }
 
 const RobotDetailsDialog = (props: RobotDetailsDialogProps) => {
-  const robot = useAppSelector(state => state.config.robots[props.robot]);
-  const ros = useConnection(robot.connection);
-  const poseStamped = useTopic<PoseStamped>(ros, robot.poseTopic || "", 'geometry_msgs/PoseStamped');
+  const robot = useRobot(props.robot);
+  const poseStamped = useTopic(robot?.rosbridge.uri, robot?.topics.pose, 'geometry_msgs/PoseStamped');
   const [showEditDialog, setShowEditDialog] = useState(false);
-  const theme = useTheme();
-
-  let color = 'transparent';
-  let messageTitle = '';
-
-  if (props.messageLevel === DiagnosticStatusLevel.OK) {
-    color = theme.palette.info.main;
-    messageTitle = 'Info';
-  } else if (props.messageLevel === DiagnosticStatusLevel.WARN) {
-    color = theme.palette.warning.main;
-    messageTitle = 'Warning';
-  } else if (props.messageLevel === DiagnosticStatusLevel.ERROR) {
-    color = theme.palette.error.main;
-    messageTitle = 'Error';
-  }
 
   return (
     <Dialog
@@ -97,25 +59,6 @@ const RobotDetailsDialog = (props: RobotDetailsDialogProps) => {
             &nbsp;
             { Math.round((poseStamped?.pose?.orientation.z || 0) * 100) / 100 } 
           </Box>
-          <Box
-            component="div"
-            sx={{
-              color,
-              visibility: props.message ? 'visible' : 'hidden',
-            }}
-          >
-            <h2>{ messageTitle }</h2>
-            { props.message }
-            <Box
-              component="div"
-            >
-              <Button
-                onClick={props.clearMessage}
-              >
-                Clear Message
-              </Button>
-            </Box>
-          </Box>
           <Box
             component="div"
             sx={{
@@ -164,11 +107,6 @@ const RobotDetailsDialog = (props: RobotDetailsDialogProps) => {
           >
             Edit
           </Button>
-          <RobotDialog
-            open={showEditDialog}
-            close={() => setShowEditDialog(false)}
-            edit={props.robot}
-          />
         </Box>
 
         <Split
@@ -176,13 +114,13 @@ const RobotDetailsDialog = (props: RobotDetailsDialogProps) => {
           horizontal
         >
           <Viewport />
-          <ImageCarousel
+          {/*<ImageCarousel
             connectionName={robot.connection}
             prefix={robot.prefix}
             sx={{
               height: "100%",
             }}
-          />
+          />*/}
         </Split>
       </Split>
     </Dialog>
diff --git a/src/features/robots/RobotDialog.tsx b/src/features/robots/RobotDialog.tsx
deleted file mode 100644
index dc7a08b6c2d28ef0f261f1bd9147cc54daf52a7f..0000000000000000000000000000000000000000
--- a/src/features/robots/RobotDialog.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import { MenuItem } from '@mui/material';
-import Button from '@mui/material/Button';
-import FormControl from '@mui/material/FormControl';
-import TextField from '@mui/material/TextField';
-import { useEffect, useState } from 'react';
-import { useAppDispatch, useAppSelector } from '../../app/hooks';
-import { robotAdded, robotRemoved, RobotType } from '../config/config-slice';
-import { Topic, useTopics, useTopicsOfType } from '../connections/RosbridgeConnections';
-import Dialog from '../../common/Dialog';
-
-export interface RobotDialogProps {
-  open?: boolean;
-  edit?: string;
-  close: () => void;
-}
-
-function extractPrefixes(topics: Topic[]) {
-  const prefixes = ["/"];
-  for (const topic of topics) {
-    for (let separatorPosition = topic.name.indexOf('/');
-         separatorPosition !== -1;
-         separatorPosition = topic.name.indexOf('/', separatorPosition + 1)) {
-      const prefix = topic.name.substring(0, separatorPosition + 1);
-      if (prefixes.indexOf(prefix) === -1) {
-        prefixes.push(prefix);
-      }
-    }
-  }
-  return prefixes;
-}
-
-const RobotDialog = (props: RobotDialogProps) => {
-  const connections = useAppSelector(state => state.config.connections);
-  const robots = useAppSelector(state => state.config.robots);
-  const connectionNames = Object.keys(connections);
-  const dispatch = useAppDispatch();
-  const [name, setName] = useState("New Robot");
-  const [connection, setConnection] = useState("");
-  const [prefix, setPrefix] = useState("/");
-  const topics = useTopics(connection);
-  const prefixes = topics ? extractPrefixes(topics) : ["/"];
-  const [type, setType] = useState<RobotType>("Crawler");
-
-  const poseTopics = useTopicsOfType(connection, ['geometry_msgs/PoseStamped']);
-  const [poseTopic, setPoseTopic] = useState<string>();
-
-  useEffect(() => {
-    if (!props.open) {
-      return;
-    }
-
-    if (props.edit) {
-      const config = robots[props.edit];
-      setName(props.edit);
-      setConnection(config.connection);
-      setPrefix(config.prefix);
-      setPoseTopic(config.poseTopic);
-      setType(config.type);
-    } else {
-      let name = "New Robot";
-      for (let i = 2; name in robots; ++i) {
-        name = `New Robot ${i}`;
-      }
-      setName(name);
-      if (Object.keys(connections).length > 0) {
-        setConnection(Object.keys(connections)[0]);
-      }
-      setPrefix('/');
-      setType("Crawler");
-    }
-  }, [props.open, props.edit, robots, connections]);
-
-  return (
-    <Dialog
-      open={props.open || false}
-      title={props.edit ? `Edit Robot` : "Add New Robot"}
-      close={props.close}
-    >
-      <FormControl size="small" sx={{ m: 1 }}>
-        <TextField
-          label="Name"
-          variant="standard"
-          value={name}
-          error={name in robots && props.edit !== name}
-          onChange={event => setName(event.target.value)}
-        />
-        <TextField
-          label="Type"
-          variant="standard"
-          value={type}
-          onChange={event => setType(event.target.value as RobotType)}
-          select
-        >
-          <MenuItem value="Crawler">Crawler</MenuItem>
-          <MenuItem value="Airborne Drone">Airborne Drone</MenuItem>
-          <MenuItem value="Underwater Drone">Underwater Drone</MenuItem>
-        </TextField>
-        <TextField
-          label="Connection"
-          variant="standard"
-          value={connection}
-          onChange={event => setConnection(event.target.value)}
-          select
-        >
-          <MenuItem
-            key={null}
-            value={undefined}
-          >
-            None
-          </MenuItem>
-          {
-            connectionNames.map(connectionName =>
-              <MenuItem
-                key={connectionName}
-                value={connectionName}
-              >
-                {connectionName}
-              </MenuItem>
-            )
-          }
-        </TextField>
-        <TextField
-          label="Prefix"
-          variant="standard"
-          value={prefix}
-          onChange={event => setPrefix(event.target.value)}
-          select
-        >
-          <MenuItem
-            key={null}
-            value={undefined}
-          >
-            None
-          </MenuItem>
-          {
-            prefixes.map(prefix =>
-              <MenuItem
-                key={prefix}
-                value={prefix}
-              >
-                {prefix}
-              </MenuItem>
-            )
-          }
-        </TextField>
-        <TextField
-          label="Pose"
-          variant="standard"
-          value={poseTopic}
-          onChange={event => setPoseTopic(event.target.value)}
-          select
-        >
-          <MenuItem
-            key={null}
-            value={undefined}
-          >
-            None
-          </MenuItem>
-          {
-            poseTopics?.map(topic =>
-              <MenuItem
-                key={topic.name}
-                value={topic.name}
-              >
-                {topic.name}
-              </MenuItem>
-            )
-          }
-        </TextField>
-        <Button
-          variant="text"
-          disabled={name in connections}
-          onClick={() => {
-            if (props.edit) {
-              dispatch(robotRemoved(props.edit));
-            }
-            dispatch(robotAdded({ name, config: { prefix, connection, poseTopic, type } }));
-            props.close();
-          }}
-        >
-          {
-            props.edit ? 'Save' : 'Add'
-          }
-        </Button>
-      </FormControl>
-    </Dialog>
-  );
-};
-
-export default RobotDialog;
diff --git a/src/features/robots/RobotList.tsx b/src/features/robots/RobotList.tsx
index 0cca38f417e5ec45ab22538ddba5193bc2c0240a..50c5992b16b1225628503a6b6c14c596e5b3e0ba 100644
--- a/src/features/robots/RobotList.tsx
+++ b/src/features/robots/RobotList.tsx
@@ -1,136 +1,30 @@
-import { PropsWithoutRef, useEffect, useState } from 'react';
-import { RobotConfig } from '../config/config-slice';
+import { PropsWithoutRef, useCallback, useState } from 'react';
 import { useAppSelector } from '../../app/hooks';
+import { Robot as RobotConfig } from '../../schemas/Robot.schema';
 import For from '../../common/For';
 import { Box } from '@mui/system';
-import { Button, Collapse, IconButton, List, ListItem, ListItemButton, Paper, useTheme } from '@mui/material';
+import { Button, IconButton, MobileStepper, Paper, useTheme } from '@mui/material';
 import { Close as CloseIcon } from '@mui/icons-material';
 import Show from '../../common/Show';
-import ImageCarousel from '../images/ImageCarousel';
-import RobotDialog from './RobotDialog';
 import RobotDetailsDialog from './RobotDetailsDialog';
-import BatteryUnknownIcon from '@mui/icons-material/BatteryUnknown';
-import Battery20Icon from '@mui/icons-material/Battery20';
-import Battery30Icon from '@mui/icons-material/Battery30';
-import Battery50Icon from '@mui/icons-material/Battery50';
-import Battery60Icon from '@mui/icons-material/Battery60';
-import Battery80Icon from '@mui/icons-material/Battery80';
-import Battery90Icon from '@mui/icons-material/Battery90';
-import BatteryFullIcon from '@mui/icons-material/BatteryFull';
-import BatteryCharging20Icon from '@mui/icons-material/BatteryCharging20';
-import BatteryCharging30Icon from '@mui/icons-material/BatteryCharging30';
-import BatteryCharging50Icon from '@mui/icons-material/BatteryCharging50';
-import BatteryCharging60Icon from '@mui/icons-material/BatteryCharging60';
-import BatteryCharging80Icon from '@mui/icons-material/BatteryCharging80';
-import BatteryCharging90Icon from '@mui/icons-material/BatteryCharging90';
-import BatteryChargingFullIcon from '@mui/icons-material/BatteryChargingFull';
-import BatteryAlertIcon from '@mui/icons-material/BatteryAlert';
 import InfoIcon from '@mui/icons-material/Info';
-import SettingsIcon from '@mui/icons-material/Settings';
-import { useTopic, useTopicFromConnection } from '../connections/RosbridgeConnections';
-
-interface BatteryIconProps {
-  percentage?: number;
-  isCharging?: boolean;
-}
-
-function BatteryIcon(props: BatteryIconProps) {
-  if (typeof props.percentage === 'undefined') {
-    return <BatteryUnknownIcon color="error" />;
-  }
-
-  if (props.percentage < 0) {
-    return  <BatteryAlertIcon color="error" />;
-  } else if (props.percentage < 0.1) {
-    return props.isCharging ?
-      <BatteryCharging20Icon color="warning" /> :
-      <BatteryAlertIcon color="warning" />;
-  } else if (props.percentage < 0.25) {
-    return props.isCharging ?
-      <BatteryCharging20Icon color="success" /> :
-      <Battery20Icon  />;
-  } else if (props.percentage < 0.4) {
-    return props.isCharging ?
-      <BatteryCharging30Icon color="success" /> :
-      <Battery30Icon  />;
-  } else if (props.percentage < 0.55) {
-    return props.isCharging ?
-      <BatteryCharging50Icon color="success" /> :
-      <Battery50Icon  />;
-  } else if (props.percentage < 0.7) {
-    return props.isCharging ?
-      <BatteryCharging60Icon color="success" /> :
-      <Battery60Icon  />;
-  } else if (props.percentage < 0.85) {
-    return props.isCharging ?
-      <BatteryCharging80Icon color="success" /> :
-      <Battery80Icon  />;
-  } else if (props.percentage < 0.95) {
-    return props.isCharging ?
-      <BatteryCharging90Icon color="success" /> :
-      <Battery90Icon  />;
-  } else if (props.percentage <= 1.0) {
-    return props.isCharging ?
-      <BatteryChargingFullIcon color="success" /> :
-      <BatteryFullIcon  />;
-  } else { //props.percentage > 1
-    return  <BatteryAlertIcon color="error" />;
-  }
-}
+import BatteryIcon from './BatteryIcon';
+import { useStore, useTopic } from '../../common/store';
+import CompressedROSImage from '../images/CompressedImage';
+import ImageCarousel from '../images/ImageCarousel';
 
 interface RobotItemProps {
-  name: string;
-  config: RobotConfig;
-}
-
-interface BatteryState {
-  percentage: number;
-}
-
-enum DiagnosticStatusLevel {
-  OK = 0,
-  WARN = 1,
-  ERROR = 2,
-  STALE = 3,
-}
-
-interface DiagnosticStatus {
-  level: DiagnosticStatusLevel;
-  name: string;
-  message: string;
+  robot: RobotConfig;
 }
 
 function RobotItem(props: PropsWithoutRef<RobotItemProps>) {
   const [showDetailView, setShowDetailView] = useState(false);
-  const [isEditing, setIsEditing] = useState(false);
   const theme = useTheme();
 
-  const batteryState = useTopicFromConnection<BatteryState>(
-    props.config.connection,
-    `${props.config.prefix}/battery`,
-    'sensor_msgs/BatteryState'
-  );
-  const rosMessage = useTopicFromConnection<DiagnosticStatus>(
-    props.config.connection,
-    `${props.config.prefix}/status`,
-    'diagnostic_msgs/DiagnosticStatus'
-  );
-  const [message, setMessage] = useState<DiagnosticStatus>();
-  useEffect(
-    () => setMessage(rosMessage),
-    [rosMessage]
-  );
+  const batteryState = useTopic(props.robot.rosbridge.uri, props.robot.topics.batteryState, 'sensor_msgs/BatteryState');
 
   let borderColor = 'transparent';
 
-  if (message?.level === DiagnosticStatusLevel.OK) {
-    borderColor = theme.palette.info.main;
-  } else if (message?.level === DiagnosticStatusLevel.WARN) {
-    borderColor = theme.palette.warning.main;
-  } else if (message?.level === DiagnosticStatusLevel.ERROR) {
-    borderColor = theme.palette.error.main;
-  }
-
   return (
     <Box
       component="div"
@@ -144,19 +38,10 @@ function RobotItem(props: PropsWithoutRef<RobotItemProps>) {
     >
       <Show when={showDetailView}>
         <RobotDetailsDialog
-          robot={props.name}
+          robot={props.robot.name}
           close={() => setShowDetailView(false)}
-          batteryPercentage={batteryState?.percentage}
-          message={message?.message}
-          messageLevel={message?.level}
-          clearMessage={() => setMessage(undefined)}
         />
       </Show>
-      <RobotDialog
-        open={isEditing}
-        close={() => setIsEditing(false)}
-        edit={props.name}
-      />
       <Box
         component="div"
         sx={{
@@ -178,7 +63,7 @@ function RobotItem(props: PropsWithoutRef<RobotItemProps>) {
               flexGrow: 1,
             }}
           >
-            <h3>{props.name}</h3>
+            <h3>{props.robot.name}</h3>
           </Box>
           <IconButton>
             <BatteryIcon percentage={batteryState?.percentage} />
@@ -188,11 +73,6 @@ function RobotItem(props: PropsWithoutRef<RobotItemProps>) {
           >
             <InfoIcon />
           </IconButton>
-          <IconButton
-            onClick={() => setIsEditing(true)}
-          >
-            <SettingsIcon />
-          </IconButton>
         </Box>
         <Box
           component="div"
@@ -200,13 +80,15 @@ function RobotItem(props: PropsWithoutRef<RobotItemProps>) {
             position: 'relative',
           }}
         >
-          <ImageCarousel
-            connectionName={props.config.connection}
-            prefix={props.config.prefix}
-            sx={{
-              height: "200px",
-            }}
-          />
+        <Show when={props.robot.topics.images}>
+        {
+          images =>
+            <ImageCarousel
+              connection={props.robot.rosbridge.uri}
+              images={images}
+            />
+        }
+        </Show>
         </Box>
       </Box>
     </Box>
@@ -250,12 +132,6 @@ function RobotDetailView(props: PropsWithoutRef<RobotDetailViewProps>) {
           <CloseIcon />
         </Button>
       </Box>
-      <ImageCarousel
-        connectionName={props.config.connection}
-        prefix={props.config.prefix}
-        sx={{
-        }}
-      />
     </Paper>
   );
 }
@@ -264,9 +140,7 @@ export interface RobotListProps {
 }
 
 export default function RobotList(props: PropsWithoutRef<RobotListProps>) {
-  const robots = useAppSelector(state => state.config.robots);
-  const [selectedRobot, selectRobot] = useState<string>();
-  const [showDetails, setShowDetails] = useState(false);
+  const robots = useStore(useCallback(state => state.robots, []));
 
   return (
     <Paper
@@ -294,32 +168,16 @@ export default function RobotList(props: PropsWithoutRef<RobotListProps>) {
             justifyContent: "center",
           }}
         >
-          <For each={Object.keys(robots).map(name => ({ name, config: robots[name] }))}>
-            {
-            robot => 
-              <RobotItem
-                key={robot.name}
-                {...robot}
-              />
-            }
-          </For>
+        {
+          Object.keys(robots).map(name => robots[name]).map(robot =>
+            <RobotItem
+              key={robot.name}
+              robot={robot}
+            />
+          )
+        }
         </Box>
       </Box>
-      <Collapse 
-        in={showDetails}
-        onTransitionEnd={() => selectRobot(showDetails ? selectedRobot : undefined)}
-      >
-        <Show when={selectedRobot}>
-          {
-            selectedRobot => 
-              <RobotDetailView
-                name={selectedRobot}
-                config={robots[selectedRobot]}
-                onClose={() => setShowDetails(false)}
-              />
-          }
-        </Show>
-      </Collapse>
     </Paper>
   );
 }
diff --git a/src/features/robots/RobotMenu.tsx b/src/features/robots/RobotMenu.tsx
deleted file mode 100644
index a82393b45b76bfee82c9f3010e6ff46c8c7fb297..0000000000000000000000000000000000000000
--- a/src/features/robots/RobotMenu.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { Divider, IconButton, ListItem, ListItemButton } from "@mui/material";
-import PopoverListButton from "../../common/PopoverListButton";
-import { SmartToy as RobotIcon, Delete } from '@mui/icons-material';
-import { Fragment, useState } from "react";
-import { useAppDispatch, useAppSelector } from '../../app/hooks';
-import For from "../../common/For";
-import Show from "../../common/Show";
-import { RobotConfig, robotRemoved } from "../config/config-slice";
-import RobotDialog from "./RobotDialog";
-
-interface RobotsMenuEntryProps {
- name: string;
- config: RobotConfig;
-}
-
-const RobotsMenuEntry = (props: RobotsMenuEntryProps) => {
-  const dispatch = useAppDispatch();
-  // const connection = useRobot(props.name);
-  // const connectionConfig = useAppSelector(state => state.config.connections[props.name]);
-  // const [connected, setConnected] = useState(connection ? connection.isConnected : false);
-  const [connectionInfoOpen, setRobotInfoOpen] = useState(false);
-
-  // useEffect(() => {
-  //   const connectListener = () => setConnected(true);
-  //   const disconnectListener = () => setConnected(false);
-  //   if (connection) {
-  //     connection.addListener('connection', connectListener);
-  //     connection.addListener('close', disconnectListener);
-  //   }
-
-  //   return () => {
-  //     if (connection) {
-  //       connection.removeListener('connection', connectListener);
-  //       connection.removeListener('close', disconnectListener);
-  //     }
-  //   }
-  // }, [connection]);
-
-  return (
-    <ListItem>
-      <ListItemButton onClick={() => setRobotInfoOpen(true)}>
-        {props.name}
-      </ListItemButton>
-      <IconButton
-        onClick={() => dispatch(robotRemoved(props.name))}
-      >
-        <Delete />
-      </IconButton>
-    </ListItem>
-  );
-};
-
-const RobotsMenu = () => {
-  const robots = useAppSelector(state => state.config.robots);
-  const [newRobot, setNewRobot] = useState(false);
-
-  return (
-    <Fragment>
-      <RobotDialog
-        open={newRobot}
-        close={() => setNewRobot(false)}
-      />
-      <PopoverListButton
-        icon={RobotIcon}
-        title="Robots"
-      >
-        <For each={Object.keys(robots).map(name => ({ name, config: robots[name] }))}>
-          {
-          robot => <RobotsMenuEntry key={robot.name} {...robot} />
-          }
-        </For>
-        <Show when={Object.keys(robots).length > 0}>
-          <Divider />
-        </Show>
-        <ListItem>
-          <ListItemButton onClick={() => setNewRobot(true)}>Add Robot</ListItemButton>
-        </ListItem>
-      </PopoverListButton>
-    </Fragment>
-  );
-};
-
-export default RobotsMenu;
diff --git a/src/features/viewport/Mesh.tsx b/src/features/viewport/Mesh.tsx
index 2e3885b0bca0739cec2d0e599c2ef66c0039ad6e..efea1404b3eda0c6631a2ecfadc3f0f63c3ffb06 100644
--- a/src/features/viewport/Mesh.tsx
+++ b/src/features/viewport/Mesh.tsx
@@ -1,57 +1,17 @@
 import { EventHandlers } from "@react-three/fiber/dist/declarations/src/core/events";
 import { useEffect, useState } from "react";
-import { Ros, Vector3 } from "roslib";
 import { BufferGeometry, DoubleSide, Float32BufferAttribute, Uint32BufferAttribute } from "three";
-import { useTopic } from "../connections/RosbridgeConnections";
-
-export interface MeshTriangleIndices {
-  vertex_indices: [number, number, number];
-}
-
-export interface MeshGeometry {
-  vertices: Vector3[];
-  vertex_normals: Vector3[];
-  faces: MeshTriangleIndices[];
-}
-
-export interface MeshGeometryStamped {
-  uuid: string;
-  mesh_geometry: MeshGeometry;
-}
+import { useTopic } from "../../common/store";
 
 export interface MeshProps extends EventHandlers {
-  ros: Ros;
+  rosbridgeURI: string;
   topic: string;
   onGeometryUpdate?: (geometry?: BufferGeometry) => void;
 }
 
-// const quad = {
-//   vertices: new Float32Array([
-//     -1, 1, 0,
-//     1, 1, 0,
-//     -1, -1, 0,
-//     1, -1, 0,
-//   ]),
-//   indices: new Uint32Array([
-//     0, 1, 2,
-//     1, 2, 3,
-//   ]),
-// }
-
-// const triangle = {
-//   vertices: new Float32Array([
-//     -1, -1, 0,
-//     0, 1, 0,
-//     1, -1, 0,
-//   ]),
-//   indices: new Uint32Array([
-//     0, 1, 2,
-//   ]),
-// }
-
 const Mesh = (props: MeshProps) => {
-  const mesh = useTopic<MeshGeometryStamped>(props.ros, props.topic, 'mesh_msgs/MeshGeometryStamped', true);
-  const [geometry, setGeometry] = useState<BufferGeometry|undefined>();
+  const mesh = useTopic(props.rosbridgeURI, props.topic, 'mesh_msgs/MeshGeometryStamped');
+  const [geometry, setGeometry] = useState<BufferGeometry>();
 
   useEffect(() => {
     if (mesh) {
@@ -85,19 +45,6 @@ const Mesh = (props: MeshProps) => {
     }
   }, [mesh]);
 
-//   useEffect(() => {
-//     let i = 0;
-//     const x = setInterval(() => {
-//       const geometry = new BufferGeometry();
-//       geometry.setIndex(new Uint32BufferAttribute(i % 2 == 0 ? quad.indices : triangle.indices, 1));
-//       geometry.setAttribute('position', new Float32BufferAttribute(i % 2 == 0 ? quad.vertices : triangle.vertices, 3));
-//       geometry.computeVertexNormals();
-//       setGeometry(geometry);
-//       ++i;
-//     }, 1000);
-//     return () => clearInterval(x);
-//   }, []);
-
   return (
     <mesh
       onClick={props.onClick}
diff --git a/src/features/viewport/PointCloud.tsx b/src/features/viewport/PointCloud.tsx
index e80c6b3b49926f0edd7f674813e3bae92e10087f..bd4043cb7d6cff85cc91c81288431b4154aee3e8 100644
--- a/src/features/viewport/PointCloud.tsx
+++ b/src/features/viewport/PointCloud.tsx
@@ -1,65 +1,87 @@
-
 import { useEffect, useState } from "react";
-import { Ros } from "roslib";
-import { Color } from "three";
-import For from "../../common/For";
-import { useTopic } from "../connections/RosbridgeConnections";
-
+import { BufferGeometry, Float32BufferAttribute } from "three";
+import { useTopic } from "../../common/store";
 
-export interface MeshGeometryStamped {
-  // uuid: string;
-  // mesh_geometry: MeshGeometry;
-  width: number;
-  data: string;
-}
-export interface PointCloudProps {
-  ros: Ros;
+export interface PointCloud2Props {
+  rosbridgeURI: string;
   topic: string;
 }
 
-interface P {
-  x: number;
-  y: number;
-  z: number;
-  intensity: number;
-}
-
-const PointCloud = (props: PointCloudProps) => {
-  const pointCloud = useTopic<MeshGeometryStamped>(props.ros, props.topic, 'sensor_msgs/PointCloud2', true);
-  const [points, setPoints] = useState<P[]>([]);
+const PointCloud2 = (props: PointCloud2Props) => {
+  const cloud = useTopic(props.rosbridgeURI, props.topic, 'sensor_msgs/PointCloud2');
+  const [geometry, setGeometry] = useState<BufferGeometry|undefined>();
+  const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]);
 
   useEffect(() => {
-    // console.log(pointCloud?.width);
-    // console.log(pointCloud?.data);
-    if (pointCloud) {
-      const x = Uint8Array.from(atob(pointCloud.data), c => c.charCodeAt(0));
-      const floats = new Float32Array(x.buffer);
-      const ps: P[] = [];
-      for (let i = 0; i < pointCloud.width; ++i) {
-        ps.push({
-          x: floats[i * 4 + 0],
-          y: floats[i * 4 + 1],
-          z: floats[i * 4 + 2],
-          intensity: floats[i * 4 + 3],
-        });
+    if (cloud) {
+      const t0 = performance.now();
+      const uint8Array = Uint8Array.from(atob(cloud.data), c => c.charCodeAt(0));
+      const t1 = performance.now();
+      console.log(`size: ${uint8Array.length / 1000}kb`);
+
+      const bufferView = new DataView(uint8Array.buffer);
+      const geometry = new BufferGeometry();
+      const positions = new Float32Array(cloud.width * cloud.height * 3);
+      const colors = new Float32Array(cloud.width * cloud.height * 3);
+
+      const xField = cloud.fields.find(field => field.name === 'x');
+      const yField = cloud.fields.find(field => field.name === 'y');
+      const zField = cloud.fields.find(field => field.name === 'z');
+      const colorField = cloud.fields.find(field => field.name === 'rgba');
+
+      if (!xField || !yField || !zField || !colorField) {
+        return;
+      }
+
+      console.log(`Start processing ${cloud.width * cloud.height} points`);
+      const offset: [number, number, number] = [0, 0, 0];
+      const t2 = performance.now();
+
+      for (let y = 0; y < cloud.height; ++y) {
+        for (let x = 0; x < cloud.width; ++x) {
+          const byteOffset = cloud.row_step * y + x * cloud.point_step;
+          const pointOffset = y * cloud.width + x;
+
+          positions[pointOffset * 3 + 0] = -bufferView.getFloat32(byteOffset + xField.offset, !cloud.is_bigendian);
+          positions[pointOffset * 3 + 2] = bufferView.getFloat32(byteOffset + yField.offset, !cloud.is_bigendian);
+          positions[pointOffset * 3 + 1] = bufferView.getFloat32(byteOffset + zField.offset, !cloud.is_bigendian);
+
+          const color = bufferView.getUint32(byteOffset + colorField.offset, !cloud.is_bigendian);
+          colors[pointOffset * 3 + 0] = ((color >> 16) & 0xff) / 255.0;
+          colors[pointOffset * 3 + 1] = ((color >> 8) & 0xff) / 255.0;
+          colors[pointOffset * 3 + 2] = ((color >> 0) & 0xff) / 255.0;
+
+          for (let i = 0; i < 3; ++i) {
+            offset[i] = (pointOffset / (pointOffset + 1)) * offset[i] - positions[pointOffset * 3 + i] / (pointOffset + 1);
+          }
+        }
       }
-      setPoints(ps);
-      // console.log(floats);
+      setOffset(offset);
+      const t3 = performance.now();
+
+      geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
+      geometry.setAttribute('color', new Float32BufferAttribute(colors, 3));
+      setGeometry(geometry);
+
+      console.log(`Finished processing ${cloud.width * cloud.height} points`);
+      console.log(`${t1 - t0} ms`);
+      console.log(`${t2 - t1} ms`);
+      console.log(`${t3 - t2} ms`);
+
+      return () => geometry.dispose();
+    } else {
+      setGeometry(undefined);
     }
-  }, [pointCloud]);
+  }, [cloud]);
 
   return (
-    <For each={points}>
-    {
-      (p, i) =>
-      <mesh position={[-p.x, p.z, p.y]} key={i}>
-        <sphereGeometry args={[0.02]}>
-        </sphereGeometry>
-        <meshStandardMaterial color={new Color(p.intensity / 50, 0, 0)} />
-      </mesh>
-    }
-    </For>
+    <points
+      geometry={geometry}
+      position={offset}
+    >
+      <pointsMaterial size={0.01} vertexColors />
+    </points>
   );
 }
 
-export default PointCloud;
+export default PointCloud2;
diff --git a/src/features/viewport/PointCloud2.tsx b/src/features/viewport/PointCloud2.tsx
deleted file mode 100644
index 3a792db9b5bde2b4c7ceffd25f682809f6bc12a1..0000000000000000000000000000000000000000
--- a/src/features/viewport/PointCloud2.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import { useEffect, useState } from "react";
-import { Ros } from "roslib";
-import { BufferGeometry, Float32BufferAttribute, Uint32BufferAttribute } from "three";
-import { useTopic } from "../connections/RosbridgeConnections";
-
-export interface PointFieldMessage {
-  name: string;
-  offset: number;
-  datatype: number;
-  count: number;
-}
-
-export interface PointCloud2Message {
-  width: number;
-  height: number;
-  fields: PointFieldMessage[];
-  is_bigendian: boolean;
-  point_step: number;
-  row_step: number;
-  data: string;
-  is_dense: boolean;
-}
-
-export interface PointCloud2Props {
-  ros: Ros;
-  topic: string;
-  onGeometryUpdate?: (geometry?: BufferGeometry) => void;
-}
-
-const PointCloud2 = (props: PointCloud2Props) => {
-  const cloud = useTopic<PointCloud2Message>(props.ros, props.topic, 'sensor_msgs/PointCloud2', true);
-  const [geometry, setGeometry] = useState<BufferGeometry|undefined>();
-  const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]);
-
-  useEffect(() => {
-    if (cloud) {
-      const t0 = performance.now();
-      const uint8Array = Uint8Array.from(atob(cloud.data), c => c.charCodeAt(0));
-      const t1 = performance.now();
-      console.log(`size: ${uint8Array.length / 1000}kb`);
-
-      const bufferView = new DataView(uint8Array.buffer);
-      const geometry = new BufferGeometry();
-      const positions = new Float32Array(cloud.width * cloud.height * 3);
-      const colors = new Float32Array(cloud.width * cloud.height * 3);
-
-      const xField = cloud.fields.find(field => field.name === 'x');
-      const yField = cloud.fields.find(field => field.name === 'y');
-      const zField = cloud.fields.find(field => field.name === 'z');
-      const colorField = cloud.fields.find(field => field.name === 'rgba');
-
-      if (!xField || !yField || !zField || !colorField) {
-        return;
-      }
-
-      console.log(`Start processing ${cloud.width * cloud.height} points`);
-      const offset: [number, number, number] = [0, 0, 0];
-      const t2 = performance.now();
-
-      for (let y = 0; y < cloud.height; ++y) {
-        for (let x = 0; x < cloud.width; ++x) {
-          const byteOffset = cloud.row_step * y + x * cloud.point_step;
-          const pointOffset = y * cloud.width + x;
-
-          positions[pointOffset * 3 + 0] = -bufferView.getFloat32(byteOffset + xField.offset, !cloud.is_bigendian);
-          positions[pointOffset * 3 + 2] = bufferView.getFloat32(byteOffset + yField.offset, !cloud.is_bigendian);
-          positions[pointOffset * 3 + 1] = bufferView.getFloat32(byteOffset + zField.offset, !cloud.is_bigendian);
-
-          const color = bufferView.getUint32(byteOffset + colorField.offset, !cloud.is_bigendian);
-          colors[pointOffset * 3 + 0] = ((color >> 16) & 0xff) / 255.0;
-          colors[pointOffset * 3 + 1] = ((color >> 8) & 0xff) / 255.0;
-          colors[pointOffset * 3 + 2] = ((color >> 0) & 0xff) / 255.0;
-
-          for (let i = 0; i < 3; ++i) {
-            offset[i] = (pointOffset / (pointOffset + 1)) * offset[i] - positions[pointOffset * 3 + i] / (pointOffset + 1);
-          }
-        }
-      }
-      setOffset(offset);
-      const t3 = performance.now();
-
-      geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
-      geometry.setAttribute('color', new Float32BufferAttribute(colors, 3));
-      setGeometry(geometry);
-
-      console.log(`Finished processing ${cloud.width * cloud.height} points`);
-      console.log(`${t1 - t0} ms`);
-      console.log(`${t2 - t1} ms`);
-      console.log(`${t3 - t2} ms`);
-    } else {
-      // setGeometry(undefined);
-      // if (props.onGeometryUpdate) {
-      //   props.onGeometryUpdate(undefined);
-      // }
-    }
-  }, [cloud]);
-
-//   useEffect(() => {
-//     let i = 0;
-//     const x = setInterval(() => {
-//       const geometry = new BufferGeometry();
-//       geometry.setIndex(new Uint32BufferAttribute(i % 2 == 0 ? quad.indices : triangle.indices, 1));
-//       geometry.setAttribute('position', new Float32BufferAttribute(i % 2 == 0 ? quad.vertices : triangle.vertices, 3));
-//       geometry.computeVertexNormals();
-//       setGeometry(geometry);
-//       ++i;
-//     }, 1000);
-//     return () => clearInterval(x);
-//   }, []);
-
-  return (
-    <points
-      geometry={geometry}
-      position={offset}
-    >
-      <pointsMaterial size={0.01} vertexColors />
-    </points>
-  );
-}
-
-export default PointCloud2;
diff --git a/src/features/viewport/Robot.tsx b/src/features/viewport/Robot.tsx
index 687b68ffbecaae91a0f4e53794479cb14cf49aee..31cb9a97d361708305c1f9af331885aaa32af5dd 100644
--- a/src/features/viewport/Robot.tsx
+++ b/src/features/viewport/Robot.tsx
@@ -1,37 +1,24 @@
-import { useLoader } from '@react-three/fiber';
 import { PropsWithoutRef } from 'react';
 import { Euler, Quaternion } from 'three';
-import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
-import { RobotConfig } from '../config/config-slice';
-import { RosbridgeConnectionMap, useTopic } from '../connections/RosbridgeConnections';
+import { useTopic } from '../../common/store';
+import { Robot as RobotConfig } from '../../schemas/Robot.schema';
 
 export interface RobotProps {
-  name: string;
   config: RobotConfig;
-  connections: RosbridgeConnectionMap;
 }
 
-interface Pose {
- position: {x: number, y: number, z: number };
- orientation: {w: number, x: number, y: number, z: number }
-}
+export default function Robot(props: PropsWithoutRef<RobotProps>) {
+  // const model = useLoader(OBJLoader, 'crawler/Altiscan2t.obj');
+  const poseStamped = useTopic(props.config.rosbridge.uri, props.config.topics.pose, 'geometry_msgs/PoseStamped');
 
-interface PoseStamped {
-  pose: Pose;
-}
+  // If we haven't received a pose yet: do not render the robot.
+  if (!poseStamped) {
+    return null;
+  }
 
-export default function Robot(props: PropsWithoutRef<RobotProps>) {
-  const ros = props.connections[props.config.connection];
-  const model = useLoader(OBJLoader, 'crawler/Altiscan2t.obj');
-  const poseStamped = useTopic<PoseStamped>(ros, props.config.poseTopic || "", 'geometry_msgs/PoseStamped');
-  const position: [number, number, number] = [-(poseStamped?.pose.position.x || 0), poseStamped?.pose.position.z || 0, poseStamped?.pose.position.y || 0];
-  const orientation: [number, number, number, number] = [poseStamped?.pose.orientation.x || 0, poseStamped?.pose.orientation.y || 0, poseStamped?.pose.orientation.z || 0, poseStamped?.pose.orientation.w || 1];
-  const sd: [number, number, number, number] = [
-    -orientation[0],
-    orientation[2],
-    orientation[1],
-    orientation[3],
-  ];
+  // TODO: refactor this!
+  const position: [number, number, number] = [-poseStamped.pose.position.x, poseStamped.pose.position.z, poseStamped.pose.position.y];
+  const orientation: [number, number, number, number] = [-poseStamped.pose.orientation.x, poseStamped.pose.orientation.z, poseStamped.pose.orientation.y, poseStamped.pose.orientation.w];
 
   const q = new Quaternion();
   q.setFromEuler(new Euler(0, -Math.PI / 2, Math.PI), true);
@@ -39,7 +26,7 @@ export default function Robot(props: PropsWithoutRef<RobotProps>) {
   return (
     <group
       position={position}
-      quaternion={sd}
+      quaternion={orientation}
       scale={[1, 1, 1]}
     >
       <group
diff --git a/src/features/viewport/Viewport.tsx b/src/features/viewport/Viewport.tsx
index d793a47eb8ea649d28db72c123af82b05edb7ad6..624b389342f3f5dbe94218f07cdb8e34ad26e0c7 100644
--- a/src/features/viewport/Viewport.tsx
+++ b/src/features/viewport/Viewport.tsx
@@ -1,64 +1,31 @@
 import { Canvas } from '@react-three/fiber';
-import { Controllers, Hands, XR } from '@react-three/xr';
-import SkyBox from './SkyBox';
-import Water from './Water';
-import { OrbitControls, FirstPersonControls } from '../camera/Controls';
-import Show from '../../common/Show';
-import { useContext, useState } from 'react';
-import { useAppSelector } from '../../app/hooks';
+import { useState } from 'react';
 import For from '../../common/For';
+import { useStore } from '../../common/store';
 import Robot from './Robot';
-import { RosbridgeConnectionsContext } from '../connections//RosbridgeConnections';
-import Mesh from './Mesh';
-import PointCloud2 from './PointCloud2';
 
 const Viewport = () => {
-  const robots = useAppSelector(state => state.config.robots);
-  const cameraControls = useAppSelector(state => state.camera.controls);
+  // const robots = useAppSelector(state => state.config.robots);
+  const robots = useStore(state => state.robots);
+  // const cameraControls = useAppSelector(state => state.camera.controls);
   const [isInXr, setIsInXr] = useState(false);
-  const connections = useContext(RosbridgeConnectionsContext);
 
   return (
-    <>
-      <Canvas 
-        tabIndex={0}
-        style={{
-        }}
-      >
-        <Show when={!isInXr}>
-          <Show when={cameraControls === 'orbit'}>
-            <OrbitControls />
-          </Show>
-          <Show when={cameraControls === 'first-person'}>
-            <FirstPersonControls />
-          </Show>
-          <></>
-        </Show>
-        <XR
-          onSessionStart={() => setIsInXr(true)}
-          onSessionEnd={() => setIsInXr(false)}
-        >
-          <Controllers />
-          <Hands />
-          <SkyBox baseURL="skyboxes/clouds" />
-          <Water width={1000} height={1000} waterNormalsTexture="waternormals.jpg" />
-          <ambientLight />
-          <pointLight position={[10, 10, 10]} />
-          <PointCloud2 ros={connections['New Connection']} topic="/crawler/leica_pts_parser/cloud" />
-          <For each={Object.keys(robots)}>
-           {
-           robot =>
-            <Robot 
-              name={robot}
-              config={robots[robot]}
-              key={robot}
-              connections={connections}
-            />
-           }
-          </For>
-        </XR>
-      </Canvas>
-    </>
+    <Canvas 
+      tabIndex={0}
+      style={{
+      }}
+    >
+      <For each={Object.values(robots)}>
+       {
+       robot =>
+        <Robot 
+          config={robot}
+          key={robot.name}
+        />
+       }
+      </For>
+    </Canvas>
   );
 }
 
diff --git a/src/features/viewport/Viewport2D.tsx b/src/features/viewport/Viewport2D.tsx
index 48bd5a90da7750af6f7e1e67af57dc2c135e4036..9579718b494197ed0cd9b36134a0d89dc04d462d 100644
--- a/src/features/viewport/Viewport2D.tsx
+++ b/src/features/viewport/Viewport2D.tsx
@@ -1,10 +1,8 @@
 import { Canvas } from '@react-three/fiber';
-import { useContext, useEffect, useRef, useState } from 'react';
+import { useState } from 'react';
 import { useAppSelector } from '../../app/hooks';
 import For from '../../common/For';
 import Robot from './Robot';
-import { RosbridgeConnectionsContext } from '../connections//RosbridgeConnections';
-import Mesh from './Mesh';
 import { OrthographicCamera } from '@react-three/drei';
 import { Box } from '@mui/system';
 import LoopIcon from '@mui/icons-material/Loop';
@@ -19,7 +17,6 @@ export type ShipSide = 'portside' | 'starboard';
 
 const Viewport2D = () => {
   const robots = useAppSelector(state => state.config.robots);
-  const connections = useContext(RosbridgeConnectionsContext);
   const [side, setSide] = useState<ShipSide>('portside');
   const [scale, setScale] = useState(1.0);
   const [rightButtonDown, setRightButtonDown] = useState(false);
@@ -86,15 +83,13 @@ const Viewport2D = () => {
           far={10000}
         />
         <ambientLight />
-        <Mesh ros={connections['New Connection']} topic="/crawler/leica_pts_parser/mesh" />
-        <For each={Object.keys(robots)}>
+        {/*<Mesh ros={connections['New Connection']} topic="/crawler/leica_pts_parser/mesh" />*/}
+        <For each={robots}>
          {
          robot =>
           <Robot 
-            name={robot}
-            config={robots[robot]}
-            key={robot}
-            connections={connections}
+            config={robot}
+            key={robot.name}
           />
          }
         </For>
diff --git a/src/index.tsx b/src/index.tsx
index 5b024ad85166ef0b2927e4194763ada565566172..0cccf21cfcac137b4437b4731668075b2cdb0c5c 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -5,15 +5,12 @@ import { BrowserRouter } from 'react-router-dom';
 import { store } from './app/store';
 import App from './App'
 import './index.css'
-import RosbridgeConnections from './features/connections/RosbridgeConnections';
 
 ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
   <React.StrictMode>
     <Provider store={store}>
       <BrowserRouter>
-        <RosbridgeConnections>
-          <App />
-        </RosbridgeConnections>
+        <App />
       </BrowserRouter>
     </Provider>
   </React.StrictMode>
diff --git a/yarn.lock b/yarn.lock
index 8b22f344e0fbbda54b4b44eaf436ed7af0f8413f..1ac143e4132869571885545be8e82163d011b5c8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1091,6 +1091,16 @@
     "@babel/helper-validator-identifier" "^7.18.6"
     to-fast-properties "^2.0.0"
 
+"@bcherny/json-schema-ref-parser@9.0.9":
+  version "9.0.9"
+  resolved "https://registry.yarnpkg.com/@bcherny/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#09899d405bc708c0acac0066ae8db5b94d465ca4"
+  integrity sha512-vmEmnJCfpkLdas++9OYg6riIezTYqTHpqUTODJzHLzs5UnXujbOJW9VwcVCnyo1mVRt32FRr23iXBx/sX8YbeQ==
+  dependencies:
+    "@jsdevtools/ono" "^7.1.3"
+    "@types/json-schema" "^7.0.6"
+    call-me-maybe "^1.0.1"
+    js-yaml "^4.1.0"
+
 "@bcoe/v8-coverage@^0.2.3":
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -1688,6 +1698,11 @@
     "@jridgewell/resolve-uri" "^3.0.3"
     "@jridgewell/sourcemap-codec" "^1.4.10"
 
+"@jsdevtools/ono@^7.1.3":
+  version "7.1.3"
+  resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
+  integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
+
 "@leichtgewicht/ip-codec@^2.0.1":
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b"
@@ -2302,6 +2317,14 @@
     "@types/qs" "*"
     "@types/serve-static" "*"
 
+"@types/glob@^7.1.3":
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb"
+  integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==
+  dependencies:
+    "@types/minimatch" "*"
+    "@types/node" "*"
+
 "@types/graceful-fs@^4.1.2":
   version "4.1.5"
   resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15"
@@ -2369,7 +2392,7 @@
     jest-matcher-utils "^27.0.0"
     pretty-format "^27.0.0"
 
-"@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
+"@types/json-schema@*", "@types/json-schema@^7.0.11", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
   version "7.0.11"
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
   integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
@@ -2379,11 +2402,21 @@
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
 
+"@types/lodash@^4.14.182":
+  version "4.14.189"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.189.tgz#975ff8c38da5ae58b751127b19ad5e44b5b7f6d2"
+  integrity sha512-kb9/98N6X8gyME9Cf7YaqIMvYGnBSWqEci6tiettE6iJWH1XdJz/PO8LB0GtLCG7x8dU3KWhZT+lA1a35127tA==
+
 "@types/mime@*":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
   integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
 
+"@types/minimatch@*":
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca"
+  integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
+
 "@types/node@*", "@types/node@>=10.0.0":
   version "18.7.18"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154"
@@ -2409,6 +2442,11 @@
   resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.0.tgz#ea03e9f0376a4446f44797ca19d9c46c36e352dc"
   integrity sha512-RI1L7N4JnW5gQw2spvL7Sllfuf1SaHdrZpCHiBlCXjIlufi1SMNnbu2teze3/QE67Fg2tBlH7W+mi4hVNk4p0A==
 
+"@types/prettier@^2.6.1":
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.1.tgz#dfd20e2dc35f027cdd6c1908e80a5ddc7499670e"
+  integrity sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==
+
 "@types/prop-types@*", "@types/prop-types@^15.7.5":
   version "15.7.5"
   resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
@@ -2842,6 +2880,11 @@ abab@^2.0.3, abab@^2.0.5:
   resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
   integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==
 
+abbrev@1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+  integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
+
 accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8:
   version "1.3.8"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
@@ -2992,6 +3035,11 @@ ansi-styles@^5.0.0:
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
   integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
 
+any-promise@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
+  integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
+
 anymatch@^3.0.3, anymatch@~3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
@@ -3429,6 +3477,11 @@ call-bind@^1.0.0, call-bind@^1.0.2:
     function-bind "^1.1.1"
     get-intrinsic "^1.0.2"
 
+call-me-maybe@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa"
+  integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==
+
 callsites@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
@@ -3534,7 +3587,7 @@ chevrotain@^10.1.2:
     lodash "4.17.21"
     regexp-to-ast "0.5.0"
 
-chokidar@^3.4.2, chokidar@^3.5.3:
+chokidar@^3.4.2, chokidar@^3.5.2, chokidar@^3.5.3:
   version "3.5.3"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
   integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@@ -3571,6 +3624,17 @@ clean-css@^5.2.2:
   dependencies:
     source-map "~0.6.0"
 
+cli-color@^2.0.2:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.3.tgz#73769ba969080629670f3f2ef69a4bf4e7cc1879"
+  integrity sha512-OkoZnxyC4ERN3zLzZaY9Emb7f/MhBOIpePv0Ycok0fJYT+Ouo00UBEIwsVsr0yoow++n5YWlSUgST9GKhNHiRQ==
+  dependencies:
+    d "^1.0.1"
+    es5-ext "^0.10.61"
+    es6-iterator "^2.0.3"
+    memoizee "^0.4.15"
+    timers-ext "^0.1.7"
+
 cliui@^7.0.2:
   version "7.0.4"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
@@ -3580,6 +3644,15 @@ cliui@^7.0.2:
     strip-ansi "^6.0.0"
     wrap-ansi "^7.0.0"
 
+cliui@^8.0.1:
+  version "8.0.1"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
+  integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==
+  dependencies:
+    string-width "^4.2.0"
+    strip-ansi "^6.0.1"
+    wrap-ansi "^7.0.0"
+
 clsx@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
@@ -3700,6 +3773,21 @@ concat-map@0.0.1:
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
 
+concurrently@^7.5.0:
+  version "7.5.0"
+  resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-7.5.0.tgz#4dd432d4634a8251f27ab000c4974e78e3906bd3"
+  integrity sha512-5E3mwiS+i2JYBzr5BpXkFxOnleZTMsG+WnE/dCG4/P+oiVXrbmrBwJ2ozn4SxwB2EZDrKR568X+puVohxz3/Mg==
+  dependencies:
+    chalk "^4.1.0"
+    date-fns "^2.29.1"
+    lodash "^4.17.21"
+    rxjs "^7.0.0"
+    shell-quote "^1.7.3"
+    spawn-command "^0.0.2-1"
+    supports-color "^8.1.0"
+    tree-kill "^1.2.2"
+    yargs "^17.3.1"
+
 confusing-browser-globals@^1.0.11:
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81"
@@ -4005,6 +4093,14 @@ csstype@^3.0.2, csstype@^3.1.0:
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9"
   integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
 
+d@1, d@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a"
+  integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==
+  dependencies:
+    es5-ext "^0.10.50"
+    type "^1.0.1"
+
 damerau-levenshtein@^1.0.8:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -4019,6 +4115,11 @@ data-urls@^2.0.0:
     whatwg-mimetype "^2.3.0"
     whatwg-url "^8.0.0"
 
+date-fns@^2.29.1:
+  version "2.29.3"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8"
+  integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==
+
 debounce@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
@@ -4453,6 +4554,42 @@ es-to-primitive@^1.2.1:
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
+es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@^0.10.61, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46:
+  version "0.10.62"
+  resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.62.tgz#5e6adc19a6da524bf3d1e02bbc8960e5eb49a9a5"
+  integrity sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==
+  dependencies:
+    es6-iterator "^2.0.3"
+    es6-symbol "^3.1.3"
+    next-tick "^1.1.0"
+
+es6-iterator@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
+  integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==
+  dependencies:
+    d "1"
+    es5-ext "^0.10.35"
+    es6-symbol "^3.1.1"
+
+es6-symbol@^3.1.1, es6-symbol@^3.1.3:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18"
+  integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==
+  dependencies:
+    d "^1.0.1"
+    ext "^1.1.2"
+
+es6-weak-map@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53"
+  integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==
+  dependencies:
+    d "1"
+    es5-ext "^0.10.46"
+    es6-iterator "^2.0.3"
+    es6-symbol "^3.1.1"
+
 escalade@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
@@ -4752,6 +4889,14 @@ etag@~1.8.1:
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
   integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
 
+event-emitter@^0.3.5:
+  version "0.3.5"
+  resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
+  integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==
+  dependencies:
+    d "1"
+    es5-ext "~0.10.14"
+
 eventemitter2@^6.4.0:
   version "6.4.9"
   resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.9.tgz#41f2750781b4230ed58827bc119d293471ecb125"
@@ -4845,6 +4990,13 @@ express@^4.17.3:
     utils-merge "1.0.1"
     vary "~1.1.2"
 
+ext@^1.1.2:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f"
+  integrity sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==
+  dependencies:
+    type "^2.7.2"
+
 fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
@@ -5135,6 +5287,11 @@ get-package-type@^0.1.0:
   resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
   integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
 
+get-stdin@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53"
+  integrity sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==
+
 get-stream@^6.0.0:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
@@ -5162,6 +5319,13 @@ glob-parent@^6.0.1, glob-parent@^6.0.2:
   dependencies:
     is-glob "^4.0.3"
 
+glob-promise@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/glob-promise/-/glob-promise-4.2.2.tgz#15f44bcba0e14219cd93af36da6bb905ff007877"
+  integrity sha512-xcUzJ8NWN5bktoTIX7eOclO1Npxd/dyVqUJxlLIDasT4C7KZyqlPIwkdJ0Ypiy3p2ZKahTjK4M9uC3sNSfNMzw==
+  dependencies:
+    "@types/glob" "^7.1.3"
+
 glob-to-regexp@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
@@ -5474,6 +5638,11 @@ identity-obj-proxy@^3.0.0:
   dependencies:
     harmony-reflect "^1.4.6"
 
+ignore-by-default@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
+  integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==
+
 ignore@^5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
@@ -5662,6 +5831,11 @@ is-potential-custom-element-name@^1.0.1:
   resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
   integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
 
+is-promise@^2.2.2:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1"
+  integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==
+
 is-regex@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
@@ -6394,6 +6568,26 @@ json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1:
   resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
   integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
 
+json-schema-to-typescript@^11.0.2:
+  version "11.0.2"
+  resolved "https://registry.yarnpkg.com/json-schema-to-typescript/-/json-schema-to-typescript-11.0.2.tgz#80348391abb4ffb75daf312380c2f01c552ffba8"
+  integrity sha512-XRyeXBJeo/IH4eTP5D1ptX78vCvH86nMDt2k3AxO28C3uYWEDmy4mgPyMpb8bLJ/pJMElOGuQbnKR5Y6NSh3QQ==
+  dependencies:
+    "@bcherny/json-schema-ref-parser" "9.0.9"
+    "@types/json-schema" "^7.0.11"
+    "@types/lodash" "^4.14.182"
+    "@types/prettier" "^2.6.1"
+    cli-color "^2.0.2"
+    get-stdin "^8.0.0"
+    glob "^7.1.6"
+    glob-promise "^4.2.2"
+    is-glob "^4.0.3"
+    lodash "^4.17.21"
+    minimist "^1.2.6"
+    mkdirp "^1.0.4"
+    mz "^2.7.0"
+    prettier "^2.6.2"
+
 json-schema-traverse@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
@@ -6440,6 +6634,11 @@ jsonpointer@^5.0.0:
   resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559"
   integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==
 
+jsonschema@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.1.tgz#cc4c3f0077fb4542982973d8a083b6b34f482dab"
+  integrity sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==
+
 "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.2:
   version "3.3.3"
   resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz#76b3e6e6cece5c69d49a5792c3d01bd1a0cdc7ea"
@@ -6623,6 +6822,13 @@ lru-cache@^6.0.0:
   dependencies:
     yallist "^4.0.0"
 
+lru-queue@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3"
+  integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==
+  dependencies:
+    es5-ext "~0.10.2"
+
 lz-string@^1.4.4:
   version "1.4.4"
   resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
@@ -6671,6 +6877,20 @@ memfs@^3.1.2, memfs@^3.4.3:
   dependencies:
     fs-monkey "^1.0.3"
 
+memoizee@^0.4.15:
+  version "0.4.15"
+  resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.15.tgz#e6f3d2da863f318d02225391829a6c5956555b72"
+  integrity sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==
+  dependencies:
+    d "^1.0.1"
+    es5-ext "^0.10.53"
+    es6-weak-map "^2.0.3"
+    event-emitter "^0.3.5"
+    is-promise "^2.2.2"
+    lru-queue "^0.1.0"
+    next-tick "^1.1.0"
+    timers-ext "^0.1.7"
+
 merge-descriptors@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
@@ -6769,6 +6989,11 @@ minimist@^1.2.0, minimist@^1.2.6:
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
   integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
 
+mkdirp@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
 mkdirp@~0.5.1:
   version "0.5.6"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
@@ -6804,6 +7029,15 @@ multicast-dns@^7.2.5:
     dns-packet "^5.2.2"
     thunky "^1.0.2"
 
+mz@^2.7.0:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
+  integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
+  dependencies:
+    any-promise "^1.0.0"
+    object-assign "^4.0.1"
+    thenify-all "^1.0.0"
+
 nanoid@^3.3.4:
   version "3.3.4"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
@@ -6824,6 +7058,11 @@ neo-async@^2.6.2:
   resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
   integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
 
+next-tick@1, next-tick@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb"
+  integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==
+
 no-case@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d"
@@ -6847,6 +7086,29 @@ node-releases@^2.0.6:
   resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
   integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
 
+nodemon@^2.0.7:
+  version "2.0.20"
+  resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.20.tgz#e3537de768a492e8d74da5c5813cb0c7486fc701"
+  integrity sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw==
+  dependencies:
+    chokidar "^3.5.2"
+    debug "^3.2.7"
+    ignore-by-default "^1.0.1"
+    minimatch "^3.1.2"
+    pstree.remy "^1.1.8"
+    semver "^5.7.1"
+    simple-update-notifier "^1.0.7"
+    supports-color "^5.5.0"
+    touch "^3.1.0"
+    undefsafe "^2.0.5"
+
+nopt@~1.0.10:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee"
+  integrity sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==
+  dependencies:
+    abbrev "1"
+
 normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
@@ -6869,6 +7131,14 @@ npm-run-path@^4.0.1:
   dependencies:
     path-key "^3.0.0"
 
+npm-watch@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/npm-watch/-/npm-watch-0.11.0.tgz#d052d9832ad2923dcf937a35aff0c2db678a8a2e"
+  integrity sha512-wAOd0moNX2kSA2FNvt8+7ORwYaJpQ1ZoWjUYdb1bBCxq4nkWuU0IiJa9VpVxrj5Ks+FGXQd62OC/Bjk0aSr+dg==
+  dependencies:
+    nodemon "^2.0.7"
+    through2 "^4.0.2"
+
 nth-check@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
@@ -6888,7 +7158,7 @@ nwsapi@^2.2.0:
   resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0"
   integrity sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==
 
-object-assign@^4, object-assign@^4.0.0, object-assign@^4.1.1:
+object-assign@^4, object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
@@ -7777,6 +8047,11 @@ prelude-ls@~1.1.2:
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
   integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==
 
+prettier@^2.6.2:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64"
+  integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==
+
 pretty-bytes@^5.3.0, pretty-bytes@^5.4.1:
   version "5.6.0"
   resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
@@ -7860,6 +8135,16 @@ psl@^1.1.33:
   resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
   integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
 
+pstree.remy@^1.1.8:
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a"
+  integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==
+
+punycode@1.3.2:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
+  integrity sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==
+
 punycode@^2.1.0, punycode@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
@@ -7877,6 +8162,11 @@ qs@6.10.3:
   dependencies:
     side-channel "^1.0.4"
 
+querystring@0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
+  integrity sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==
+
 querystringify@^2.1.1:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
@@ -8178,6 +8468,15 @@ read-cache@^1.0.0:
   dependencies:
     pify "^2.3.0"
 
+readable-stream@3, readable-stream@^3.0.6:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
+  integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
 readable-stream@^2.0.1:
   version "2.3.7"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
@@ -8191,15 +8490,6 @@ readable-stream@^2.0.1:
     string_decoder "~1.1.1"
     util-deprecate "~1.0.1"
 
-readable-stream@^3.0.6:
-  version "3.6.0"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
-  integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
-  dependencies:
-    inherits "^2.0.3"
-    string_decoder "^1.1.1"
-    util-deprecate "^1.0.1"
-
 readdirp@~3.6.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@@ -8464,6 +8754,13 @@ run-parallel@^1.1.9:
   dependencies:
     queue-microtask "^1.2.2"
 
+rxjs@^7.0.0:
+  version "7.5.7"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39"
+  integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==
+  dependencies:
+    tslib "^2.1.0"
+
 safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@ -8567,6 +8864,11 @@ selfsigned@^2.0.1:
   dependencies:
     node-forge "^1"
 
+semver@^5.7.1:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
+  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
+
 semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
@@ -8579,6 +8881,11 @@ semver@^7.3.2, semver@^7.3.5, semver@^7.3.7:
   dependencies:
     lru-cache "^6.0.0"
 
+semver@~7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
+  integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
+
 send@0.18.0:
   version "0.18.0"
   resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be"
@@ -8681,6 +8988,13 @@ signal-exit@^3.0.2, signal-exit@^3.0.3:
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
   integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
 
+simple-update-notifier@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-1.0.7.tgz#7edf75c5bdd04f88828d632f762b2bc32996a9cc"
+  integrity sha512-BBKgR84BJQJm6WjWFMHgLVuo61FBDSj1z/xSFUIozqO6wO7ii0JxCqlIud7Enr/+LhlbNI0whErq96P2qHNWew==
+  dependencies:
+    semver "~7.0.0"
+
 sisteransi@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
@@ -8784,6 +9098,11 @@ sourcemap-codec@^1.4.8:
   resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
   integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
 
+spawn-command@^0.0.2-1:
+  version "0.0.2-1"
+  resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0"
+  integrity sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==
+
 spdy-transport@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31"
@@ -8865,7 +9184,7 @@ string-natural-compare@^3.0.1:
   resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
   integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
 
-string-width@^4.1.0, string-width@^4.2.0:
+string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -8998,7 +9317,7 @@ stylis@4.0.13:
   resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.13.tgz#f5db332e376d13cc84ecfe5dace9a2a51d954c91"
   integrity sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==
 
-supports-color@^5.3.0:
+supports-color@^5.3.0, supports-color@^5.5.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
   integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
@@ -9012,7 +9331,7 @@ supports-color@^7.0.0, supports-color@^7.1.0:
   dependencies:
     has-flag "^4.0.0"
 
-supports-color@^8.0.0:
+supports-color@^8.0.0, supports-color@^8.1.0:
   version "8.1.1"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
   integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
@@ -9175,6 +9494,20 @@ text-table@^0.2.0:
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
   integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
 
+thenify-all@^1.0.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
+  integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
+  dependencies:
+    thenify ">= 3.1.0 < 4"
+
+"thenify@>= 3.1.0 < 4":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
+  integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
+  dependencies:
+    any-promise "^1.0.0"
+
 three-mesh-bvh@^0.5.15:
   version "0.5.17"
   resolved "https://registry.yarnpkg.com/three-mesh-bvh/-/three-mesh-bvh-0.5.17.tgz#1f9bdcd7992e86dc3665664c1fe5cedd7be4bd9f"
@@ -9224,11 +9557,26 @@ throat@^6.0.1:
   resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375"
   integrity sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==
 
+through2@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-4.0.2.tgz#a7ce3ac2a7a8b0b966c80e7c49f0484c3b239764"
+  integrity sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==
+  dependencies:
+    readable-stream "3"
+
 thunky@^1.0.2:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
   integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
 
+timers-ext@^0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.7.tgz#6f57ad8578e07a3fb9f91d9387d65647555e25c6"
+  integrity sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==
+  dependencies:
+    es5-ext "~0.10.46"
+    next-tick "1"
+
 tiny-inflate@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4"
@@ -9256,6 +9604,13 @@ toidentifier@1.0.1:
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
   integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
 
+touch@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
+  integrity sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==
+  dependencies:
+    nopt "~1.0.10"
+
 tough-cookie@^4.0.0:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.2.tgz#e53e84b85f24e0b65dd526f46628db6c85f6b874"
@@ -9280,6 +9635,11 @@ tr46@^2.1.0:
   dependencies:
     punycode "^2.1.1"
 
+tree-kill@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
+  integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
+
 troika-three-text@^0.46.4:
   version "0.46.4"
   resolved "https://registry.yarnpkg.com/troika-three-text/-/troika-three-text-0.46.4.tgz#77627ac2ac4765d5248c857a8b42f82c25f2d034"
@@ -9325,6 +9685,11 @@ tslib@^2.0.3:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
   integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
 
+tslib@^2.1.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
+  integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
+
 tsutils@^3.21.0:
   version "3.21.0"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
@@ -9374,6 +9739,16 @@ type-is@~1.6.18:
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
+type@^1.0.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0"
+  integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==
+
+type@^2.7.2:
+  version "2.7.2"
+  resolved "https://registry.yarnpkg.com/type/-/type-2.7.2.tgz#2376a15a3a28b1efa0f5350dcf72d24df6ef98d0"
+  integrity sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==
+
 typedarray-to-buffer@^3.1.5:
   version "3.1.5"
   resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
@@ -9396,6 +9771,11 @@ unbox-primitive@^1.0.2:
     has-symbols "^1.0.3"
     which-boxed-primitive "^1.0.2"
 
+undefsafe@^2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"
+  integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==
+
 unicode-canonical-property-names-ecmascript@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"
@@ -9474,7 +9854,15 @@ url-parse@^1.5.3:
     querystringify "^2.1.1"
     requires-port "^1.0.0"
 
-use-sync-external-store@^1.0.0:
+url@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
+  integrity sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==
+  dependencies:
+    punycode "1.3.2"
+    querystring "0.2.0"
+
+use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
   integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
@@ -10038,6 +10426,11 @@ yargs-parser@^20.2.2:
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
   integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
 
+yargs-parser@^21.1.1:
+  version "21.1.1"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
+  integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
+
 yargs@^16.2.0:
   version "16.2.0"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
@@ -10051,6 +10444,19 @@ yargs@^16.2.0:
     y18n "^5.0.5"
     yargs-parser "^20.2.2"
 
+yargs@^17.3.1:
+  version "17.6.2"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.6.2.tgz#2e23f2944e976339a1ee00f18c77fedee8332541"
+  integrity sha512-1/9UrdHjDZc0eOU0HxOHoS78C69UD3JRMvzlJ7S79S2nTaWRA/whGCTV8o9e/N/1Va9YIV7Q4sOxD8VV4pCWOw==
+  dependencies:
+    cliui "^8.0.1"
+    escalade "^3.1.1"
+    get-caller-file "^2.0.5"
+    require-directory "^2.1.1"
+    string-width "^4.2.3"
+    y18n "^5.0.5"
+    yargs-parser "^21.1.1"
+
 yocto-queue@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
@@ -10065,3 +10471,10 @@ zustand@^3.5.13, zustand@^3.7.1:
   version "3.7.2"
   resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.7.2.tgz#7b44c4f4a5bfd7a8296a3957b13e1c346f42514d"
   integrity sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==
+
+zustand@^4.1.4:
+  version "4.1.4"
+  resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.1.4.tgz#b0286da4cc9edd35e91c96414fa54bfa4652a54d"
+  integrity sha512-k2jVOlWo8p4R83mQ+/uyB8ILPO2PCJOf+QVjcL+1PbMCk1w5OoPYpAIxy9zd93FSfmJqoH6lGdwzzjwqJIRU5A==
+  dependencies:
+    use-sync-external-store "1.2.0"