Repository: SimplyPrint/printer-ws-client (language: python)

Issue:
python compatibility
add python compatibility for versions less that 3.11


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 05e145b..5d92665 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,7 +62,6 @@ db.sqlite3
 db.sqlite3-journal
 
 # Flask stuff:
-instance/
 .webassets-cache
 
 # Scrapy stuff:
@@ -161,4 +160,6 @@ cython_debug/
 
 config.json
 virtual_local
-Pipfile*
\ No newline at end of file
+Pipfile*
+config.db
+.vscode
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..9ee86e7
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,6 @@
+{
+    "[python]": {
+        "editor.defaultFormatter": "ms-python.autopep8"
+    },
+    "python.formatting.provider": "none"
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 459603d..0b67e4c 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,15 @@
-# Printer WS Client
+# SimplyPrint Websocket Client
 
-A package for easy implementations of SimplyPrint printers.
+> A python package to simplify the use of the SimplyPrint websocket API to easily integrate with the <https://simplyprint.io> platform.
 
-## Usage
-
-```python
-from printer_ws_client import *
-
-class MyClient(Client):
-    def __init__(self):
-        self.info.ui = "My Ui"
-        self.info.ui_version = "0.0.1"
+This package version v1.0.0 is under major development and is not yet ready for use. API is not stable and will change.
 
-        self.info.api = "My Api"
-        self.info.api_version = "4.2.0"
-
-        self.local_path = "path_to_local_files"
-
-    # define a callback
-    async def on_connect(self, event: ConnectEvent):
-        print(f"Connected, got name: {event.name}")
+## Usage
 
-    # define another callback
-    async def on_start_print(self, _: StartPrintEvent):
-        # start the print somehow
-        start_print(self.selected_file)
+TBD
 
-my_client = MyClient()
+## TODO
 
-# start the client
-# this runs the background thread and starts even processing
-my_client.start()
+Cleanup multiplexer.py and fix disconnect flow.
 
-# run some loop
-while True:
-    sleep(1)
-    # get data from printer
-    my_client.tool_temperatures = poll_tool_temperatures()
-    my_client.bed_temperature = poll_bed_temperature()
-```
+Currently there is a disconnection bug once in a while which needs to be resolved
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 19afb67..0b09015 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "simplyprint-ws-client"
-version = "0.0.6"
+version = "1.0.0-rc.1"
 license = "AGPL-3.0-or-later"
 authors = ["SimplyPrint <contact@simplyprint.io>"]
 description = "SimplyPrint Websocket Client"
@@ -14,13 +14,14 @@ packages = [
 ]
 
 [tool.poetry.dependencies]
+click = "^8.1.7"
+psutil = "^5.9.7"
 python = "^3.8"
-tornado = "^6.2"
-psutil = "^5.9.4"
-sentry-sdk = "^1.18.0"
+aiohttp = "^3.9.1"
+traitlets = "^5.14.0"
 netifaces = "^0.11.0"
-requests = "^2.28.2"
-opencv-python = "^4.7.0.72"
+sentry-sdk = "^1.39.0"
+platformdirs = "^4.1.0"
 
 [tool.poetry.urls]
 "Bug Tracker" = "https://github.com/SimplyPrint/printer-ws-client/issues"
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 9f48716..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-tornado ~= 6.2
-psutil ~= 5.9.4
-sentry-sdk ~= 1.18.0
-netifaces ~= 0.11.0
-requests ~= 2.28.2
-# opencv-python ~= 4.7.0.72
\ No newline at end of file
diff --git a/simplyprint_ws_client/__init__.py b/simplyprint_ws_client/__init__.py
deleted file mode 100644
index 5ae780d..0000000
--- a/simplyprint_ws_client/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from .client import Client, ClientInfo, PrinterEvent
-from .event import *
-from .printer_state import Printer, Display, PrinterSettings, PrinterFirmware, PrinterStatus, Temperature
-from .connection import Connection
-from .timer import Intervals
-from .ambient import AmbientCheck
-from .async_loop import AsyncLoop
-from .config import Config, CONFIG_FILE_PATH
-from .file import FileHandler
diff --git a/simplyprint_ws_client/__main__.py b/simplyprint_ws_client/__main__.py
index 244a421..a43f02c 100644
--- a/simplyprint_ws_client/__main__.py
+++ b/simplyprint_ws_client/__main__.py
@@ -1,2 +1,16 @@
-if __name__ == "__main__":
-    pass
+from .app import ClientMode, ClientOptions
+from .cli import ClientCli
+from .config import ConfigManagerType
+from .examples.virtual.virtual_client import VirtualClient
+from .examples.virtual.virtual_config import VirtualConfig
+
+if __name__ == '__main__':
+    options = ClientOptions()
+    options.mode = ClientMode.MULTIPRINTER
+    options.config_manager_type = ConfigManagerType.JSON
+    options.client_t = VirtualClient
+    options.config_t = VirtualConfig
+    options.allow_setup = True
+
+    cli = ClientCli(options)
+    cli(prog_name="python -m simplyprint_ws_client")
diff --git a/simplyprint_ws_client/ambient.py b/simplyprint_ws_client/ambient.py
deleted file mode 100644
index 5f24a7c..0000000
--- a/simplyprint_ws_client/ambient.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import asyncio
-from .printer_state import Printer, Temperature
-from typing import Callable, List, Optional
-
-AMBIENT_CHECK_TIME = 5.0 * 60.0
-SAMPLE_CHACK_TIME = 20.0
-CHECK_INTERVAL = 5.0
-
-class AmbientCheck:
-    initial_sample: Optional[float] = None
-    ambient: float = 0
-    changed: Callable[[int], None] = lambda _: None
-    update_interval: float = AMBIENT_CHECK_TIME
-
-    def __init__(self, changed: Callable[[int], None]):
-        self.changed = changed
-
-    # detects ambient temperature
-    # uses the algorithm defined in SimplyPrint-OctoPrint
-    def detect(self, tools: List[Temperature]):
-        if len(tools) == 0:
-            self.initial_sample = None
-            self.update_interval = CHECK_INTERVAL
-            return
-
-        tool0 = tools[0]
-
-        if not tool0.target is None:
-            self.initial_sample = None
-            self.update_interval = AMBIENT_CHECK_TIME
-            return
-
-        if self.initial_sample is None:
-            self.initial_sample = tool0.actual
-            self.update_interval = SAMPLE_CHACK_TIME
-        else:
-            diff = abs(tool0.actual - self.initial_sample)
-            if diff <= 2.0:
-                last_ambient = self.ambient
-                self.ambient = (tool0.actual + self.initial_sample) / 2
-                self.initial_sample = None
-                self.update_interval = AMBIENT_CHECK_TIME
-
-                if round(last_ambient) != round(self.ambient):
-                    self.changed(round(self.ambient))
-            else:
-                self.initial_sample = tool0.actual
-                self.update_interval = SAMPLE_CHACK_TIME
-
-    # runs the ambient check in a loop
-    # spawned on start
-    async def run_loop(self, printer: Printer):
-        while True:
-            self.detect(printer.tool_temperatures)
-            await asyncio.sleep(self.update_interval)
diff --git a/simplyprint_ws_client/app.py b/simplyprint_ws_client/app.py
new file mode 100644
index 0000000..6a6ebae
--- /dev/null
+++ b/simplyprint_ws_client/app.py
@@ -0,0 +1,89 @@
+import asyncio
+import logging
+from asyncio import AbstractEventLoop
+from enum import Enum
+from typing import Optional, Type
+
+from .client import Client
+from .config import Config, ConfigManager, ConfigManagerType
+from .instance import Instance, MultiPrinter, SinglePrinter
+
+
+class ClientMode(Enum):
+    MULTIPRINTER = "mp"
+    SINGLE = "p"
+
+    def get_class(self) -> Type[Instance]:
+        if self == ClientMode.MULTIPRINTER:
+            return MultiPrinter
+        elif self == ClientMode.SINGLE:
+            return SinglePrinter
+        else:
+            raise ValueError("Invalid ClientMode")
+
+class ClientOptions:
+    mode: ClientMode
+    config_manager_type: ConfigManagerType
+
+    client_t: Optional[Type[Client]] = None
+    config_t: Optional[Type[Config]] = None
+
+    allow_setup: bool = False
+    reconnect_timeout = 5.0
+    tick_rate = 0.2
+
+class ClientFactory:
+    client_t: Optional[Type[Client]] = None
+    config_t: Optional[Type[Config]] = None
+
+    def __init__(self, client_t: Optional[Type[Client]] = None, config_t: Optional[Type[Config]] = None) -> None:
+        self.client_t = client_t
+        self.config_t = config_t
+
+    def create_client(self, *args, config: Optional[Config] = None, **kwargs) -> Client:
+        return self.client_t(*args, config=config or self.config_t.get_blank(), **kwargs)
+
+class ClientApp:
+    loop: AbstractEventLoop
+
+    logger = logging.getLogger("simplyprint.client_app")
+    instance: Instance[Client, Config]
+
+    client_factory: ClientFactory
+    config_manager: ConfigManager
+
+    def __init__(self, options: ClientOptions) -> None:
+        self.loop = asyncio.get_event_loop()
+
+        config_manager_class = options.config_manager_type.get_class()
+        instance_class = options.mode.get_class()
+
+        self.config_manager = config_manager_class(config_t=options.config_t)
+        self.instance = instance_class(loop=self.loop, config_manager=self.config_manager, allow_setup=options.allow_setup, reconnect_timeout=options.reconnect_timeout, tick_rate=options.tick_rate)
+        self.client_factory = ClientFactory(client_t=options.client_t, config_t=options.config_t)
+    
+    async def run(self):
+        # Register all clients
+        for config in self.config_manager.get_all():
+            self.logger.debug(f"Registering client {config}")
+
+            client = self.client_factory.create_client(config=config)
+            await self.instance.register_client(client)
+
+        await self.instance.run()
+
+        self.logger.debug("Client instance has stopped")
+
+    async def _add_new_client(self, config: Optional[Config]):
+        client = self.client_factory.create_client(config=config)
+        await self.instance.register_client(client)
+    
+    def add_new_client(self, config: Optional[Config]):
+        self.loop.create_task(self._add_new_client(config))
+
+    def start(self):
+        self.loop.create_task(self.run())
+        self.loop.run_forever()
+
+    def stop(self):
+        self.instance.stop()
\ No newline at end of file
diff --git a/simplyprint_ws_client/async_loop.py b/simplyprint_ws_client/async_loop.py
deleted file mode 100644
index 1e77e16..0000000
--- a/simplyprint_ws_client/async_loop.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import threading
-import asyncio
-
-from asyncio import AbstractEventLoop
-from typing import Coroutine
-from concurrent.futures import Future
-
-class AsyncLoop:
-    def __init__(self):
-        self.thread = threading.Thread(
-            target=self._run_thread, 
-            daemon=True,
-        )
-
-        self.aioloop: AbstractEventLoop = asyncio.get_event_loop()
-        asyncio.set_event_loop(self.aioloop)
-
-    def _run_thread(self) -> None:
-        self.aioloop.run_forever()
-
-    def start(self) -> None:
-        if self.thread.is_alive():
-            return
-        self.thread.start()
-
-    def stop(self) -> None:
-        self.aioloop.stop()
-        self.thread.join() 
-
-    # spawn a future on the async io thread
-    # 
-    # NOTE: this might not be optimal
-    def spawn(self, coro: Coroutine) -> Future:
-        return asyncio.run_coroutine_threadsafe(coro, self.aioloop)
diff --git a/simplyprint_ws_client/cli.py b/simplyprint_ws_client/cli.py
new file mode 100644
index 0000000..c05fbe4
--- /dev/null
+++ b/simplyprint_ws_client/cli.py
@@ -0,0 +1,120 @@
+import logging
+from typing import Any, Dict, Optional, Union, get_args, get_origin
+
+import click
+
+from .app import ClientApp, ClientOptions
+from .config import Config
+
+
+class CommandBag:
+    commands: Dict[str, click.Command]
+
+    def list_commands(self, ctx):
+        return sorted(self.commands.keys())
+    
+    def get_command(self, ctx, name):
+        return self.commands.get(name)
+    
+    def add_command(self, command: click.Command):
+        self.commands[command.name] = command
+
+
+class ClientCliConfigManager(CommandBag, click.Group):
+    """ Commands to interact with the configuration manager. """
+    def __init__(self, app: ClientApp) -> None:
+        super().__init__(name="config", help="Configuration manager commands")
+        self.app = app
+        self.commands = {}
+        self.add_command(click.Command("list", callback=self.list_configs))
+        self.add_command(click.Command("edit", callback=self.edit_config, params=[click.Argument(["index"], type=int)]))
+        self.add_command(click.Command("add", callback=self.add_config))
+        self.add_command(click.Command("remove", callback=self.remove_config, params=[click.Argument(["index"], type=int)]))
+
+    def list_configs(self):
+        configs = self.app.config_manager.get_all()
+
+        for index, config in enumerate(configs):
+            click.echo(f"{index}: {config}")
+
+    def get_config_default(self, field: str, current_value: Any) -> Any:
+        if current_value is None and field in ["id", "token"]:
+            return 0 if field == "id" else "0"
+
+        if current_value is not None:
+            return current_value
+
+        return ""
+
+    def prompt_and_update_config(self, config: Config):
+        click.echo(f"Editing configuration {config}. Leave blank to keep current value")
+        fields = list(sorted(config.__slots__))
+
+        for field in fields:
+            # Ask user for field value
+            field_type = config.__annotations__[field] if field in config.__annotations__ else str
+            
+            if get_origin(field_type) is Union:
+                field_type = get_args(field_type)[0]
+
+            field_default = self.get_config_default(field, getattr(config, field) if hasattr(config, field) else None)
+
+            value = click.prompt(f"Input {field}", default=field_default, show_default=field_default != "", type=field_type)
+
+            if value is None or value == "":
+                continue
+
+            setattr(config, field, value)
+
+    def get_config_by_index(self, index: int) -> Optional[Config]:
+        configs = self.app.config_manager.get_all()
+        if 0 <= index < len(configs):
+            return configs[index]
+        return None
+
+    def edit_config(self, index: int):
+        config = self.get_config_by_index(index)
+
+        if config:
+            self.prompt_and_update_config(config)
+            self.app.config_manager.persist(config)
+            self.app.config_manager.flush()
+            click.echo("Configuration updated.")
+        else:
+            click.echo("Configuration not found.")
+
+    def add_config(self):
+        config = self.app.config_manager.config_t()
+        self.prompt_and_update_config(config)
+        self.app.config_manager.persist(config)
+        self.app.config_manager.flush()
+        click.echo("Configuration added.")
+
+    def remove_config(self, index: int):
+        config = self.get_config_by_index(index)
+
+        if config:
+            self.app.config_manager.remove(config)
+            self.app.config_manager.flush()
+
+            click.echo("Configuration removed.")
+        else:
+            click.echo("Configuration not found.")
+
+class ClientCli(CommandBag, click.MultiCommand):
+    app: ClientApp
+
+    commands: Dict[str, click.Command]
+
+    def __init__(self, options: ClientOptions) -> None:
+        super().__init__(name="simplyprint", help="SimplyPrint client CLI")
+        self.app = ClientApp(options)
+        self.commands = {}
+
+        # Register commands
+        self.add_command(ClientCliConfigManager(self.app))
+        self.add_command(click.Command("start", callback=self.start_client, help="Start the client"))
+
+    def start_client(self):
+        logging.basicConfig(level=logging.DEBUG)
+        self.app.start()
diff --git a/simplyprint_ws_client/client.py b/simplyprint_ws_client/client.py
new file mode 100644
index 0000000..9344ba9
--- /dev/null
+++ b/simplyprint_ws_client/client.py
@@ -0,0 +1,227 @@
+import asyncio
+import time
+from abc import ABC, abstractmethod
+from typing import Generic, Optional, TypeVar
+
+from .config import Config
+from .const import SUPPORTED_SIMPLYPRINT_VERSION
+from .events import demands as Demands
+from .events import server_events as Events
+from .events.client_events import ClientEvent
+from .events.event import Event
+from .events.event_bus import EventBus
+from .helpers.intervals import IntervalTypes
+from .helpers.physical_machine import PhysicalMachine
+from .helpers.sentry import Sentry
+from .state.printer import PrinterState
+
+
+class ClientConfigurationException(Exception):
+    ...
+
+class ClientEventBus(EventBus[Event]):
+    """ 
+    Eventbus for events from SimplyPrint to the client. (ServerEvents / DemandEvents)
+
+    Also for the client to send events to SimplyPrint. (ClientEvents)
+    """
+    ...
+
+class ClientConfigChangedEvent(Event):
+    ...
+
+TConfig = TypeVar("TConfig", bound=Config)
+
+class Client(ABC, Generic[TConfig]):
+    """
+    Generic client class that handles and brokers information between the server and the client.
+
+    Not nessaicarily a printer, but a client that can be connected to the server.
+    But in some cases also an actual physical device.
+    """
+
+    config: TConfig
+    printer: PrinterState
+
+    connected: bool = False
+
+    # Usually injected by multiplexer
+    sentry: Optional[Sentry]
+    physical_machine: Optional[PhysicalMachine]
+
+    event_bus: ClientEventBus
+
+    def __init__(self, config: TConfig):
+        self.config = config
+        self.printer = PrinterState()
+
+        self.event_bus = ClientEventBus()
+
+        # Recover handles from the class
+        for name in dir(self):
+            attr = getattr(self, name)
+            if hasattr(attr, "_event"):
+                event_cls = attr._event
+                self.event_bus.on(event_cls, attr, attr._pre)
+
+    async def send_event(self, event: ClientEvent):
+        """
+        Wrapper method to send an client event to the server.
+        """
+
+        await self.event_bus.emit(event)
+
+    async def consume_state(self):
+        """
+        Consumes the state of a client to produce client events
+        which are dispatched to the bus.
+        """
+
+        for event in self.printer._build_events(self.config.id):
+            await self.send_event(event)
+
+    @abstractmethod
+    async def tick(self):
+        """ 
+        Define a continuous task that will be called every "tick"
+        this is variable and made to optimize certain performance
+        when running a lot of clients at once.
+
+        In here you can manipulate, progress or otherwise change
+        the state of the printer to control it.
+
+        Optionally can you also send your own events from inside
+        the printer context, but you can always interact with the
+        send event method externally.
+        """
+        ...
+
+
+class DefaultClient(Client[TConfig]):
+    """
+    Client with default event handling.
+    """
+
+    reconnect_token: Optional[str] = None
+
+    def __init__(self, config: TConfig):
+        super().__init__(config)
+
+        self.physical_machine = PhysicalMachine()
+        
+        self.printer.observe(self._on_display_message,
+                             "current_display_message")
+
+        for k, v in self.physical_machine.get_info().items():
+            self.printer.info.set_trait(k, v)
+
+        self.printer.info.sp_version = SUPPORTED_SIMPLYPRINT_VERSION
+
+    def set_info(self, name, version = "0.0.1"):
+        self.set_api_info(name, version)
+        self.set_ui_info(name, version)
+
+    def set_api_info(self, api: str, api_version: str):
+        self.printer.info.api = api
+        self.printer.info.api_version = api_version
+
+    def set_ui_info(self, ui: str, ui_version: str):
+        self.printer.info.ui = ui
+        self.printer.info.ui_version = ui_version
+
+    def setup_sentry(self, sentry_dsn: str, development: bool = False):
+        if not self.printer.info.api or not self.printer.info.api_version:
+            raise ClientConfigurationException(
+                "You need to set the api and api_version before you can setup sentry")
+
+        self.sentry = Sentry()
+        self.sentry.client = self.printer.info.api
+        self.sentry.client_version = self.printer.info.api_version
+        self.sentry.sentry_dsn = sentry_dsn
+        self.sentry.development = development
+
+    def _on_display_message(self, change):
+        message = change['new']
+
+        if self.printer.display_settings.branding:
+            if len(message) > 7:
+                message = f"[SP] {message}"
+            else:
+                message = f"[SimplyPrint] {message}"
+
+        # Pass on to gcode handling (Printer firmware)
+        gcode_event = Demands.GcodeEvent(name="demand", demand=Demands.GcodeEvent.demand, data={
+            "list": ["M117 {}".format(message.replace('\n', ''))]
+        })
+
+        asyncio.create_task(self.event_bus.emit(gcode_event))
+
+    @Demands.SystemRestartEvent.on
+    async def on_system_restart(self, event: Demands.SystemRestartEvent):
+        self.physical_machine.restart()
+
+    @Demands.SystemShutdownEvent.on
+    async def on_system_shutdown(self, event: Demands.SystemShutdownEvent):
+        self.physical_machine.shutdown()
+
+    @Events.ErrorEvent.before
+    async def on_error(self, event: Events.ErrorEvent):
+        ...
+
+    @Events.NewTokenEvent.before
+    async def before_new_token(self, event: Events.NewTokenEvent):
+        self.config.token = event.token
+        await self.event_bus.emit(ClientConfigChangedEvent())
+
+    @Events.ConnectEvent.before
+    async def before_connect(self, event: Events.ConnectEvent):
+        self.config.name = event.printer_name
+        self.config.in_setup = event.in_setup
+        self.connected = True
+
+        self.printer.intervals.update(event.intervals)
+
+        self.reconnect_token = event.reconnect_token
+
+        if self.config.in_setup:
+            self.printer.current_display_message = "In setup with Code: " + event.short_id
+
+        await self.event_bus.emit(ClientConfigChangedEvent())
+
+    @Events.SetupCompleteEvent.before
+    async def before_setup_complete(self, event: Events.SetupCompleteEvent):
+        self.config.id = event.printer_id
+        self.config.in_setup = False
+        self.printer.current_display_message = "Setup complete"
+        await self.event_bus.emit(ClientConfigChangedEvent())
+
+    @Events.IntervalChangeEvent.before
+    async def before_interval_change(self, event: Events.IntervalChangeEvent):
+        self.printer.intervals.update(event.intervals)
+
+    @Events.PongEvent.before
+    async def before_pong(self, event: Events.PongEvent):
+        self.printer.ping_pong.pong = time.time()
+
+    @Events.StreamReceivedEvent.before
+    async def before_stream_received(self, event: Events.StreamReceivedEvent):
+        # TODO
+        ...
+
+    @Events.PrinterSettingsEvent.before
+    async def before_printer_settings(self, event: Events.PrinterSettingsEvent):
+        self.printer.settings.has_psu = event.printer_settings.has_psu
+        self.printer.settings.has_filament_sensor = event.printer_settings.has_filament_sensor
+        self.printer.display_settings.branding = event.display_settings.branding
+        self.printer.display_settings.enabled = event.display_settings.enabled
+        self.printer.display_settings.while_printing_type = event.display_settings.while_printing_type
+        self.printer.display_settings.show_status = event.display_settings.show_status
+
+    @Demands.PauseEvent.before
+    async def before_pause(self, event: Demands.PauseEvent):
+        ...
+
+    @Demands.WebcamSnapshotEvent.before
+    async def before_webcam_snapshot(self, event: Demands.WebcamSnapshotEvent):
+        if event.timer is not None:
+            self.printer.intervals.set(IntervalTypes.WEBCAM.value, event.timer)
\ No newline at end of file
diff --git a/simplyprint_ws_client/client/__init__.py b/simplyprint_ws_client/client/__init__.py
deleted file mode 100644
index d8a7653..0000000
--- a/simplyprint_ws_client/client/__init__.py
+++ /dev/null
@@ -1,1104 +0,0 @@
-import asyncio
-import logging
-import json
-import copy
-import time
-import sentry_sdk
-import platform
-import os
-import psutil
-import functools
-import base64
-
-from concurrent.futures import Future
-from simplyprint_ws_client.client.info import ClientInfo
-
-from simplyprint_ws_client.const import AI_ENDPOINT, SNAPSHOT_ENDPOINT, VERSION
-from ..ambient import AmbientCheck
-from ..printer_state import Printer, PrinterStatus, Temperature
-from ..connection import Connection
-from ..event import *
-from ..timer import Intervals
-from ..async_loop import AsyncLoop
-from ..config import Config
-from ..file import FileHandler, requests
-from logging import Logger
-from typing import Awaitable, Callable, List, Optional, Type
-
-# Decorator for registering a handler for an event
-def register_handle(event_type: Type[Event]):
-    def decorator(handler: Callable[[Event], Awaitable[None]]):
-        handler._type = event_type
-        return handler
-    
-    return decorator
-
-def register_class_handle(cls):
-    for _, value in cls.__dict__.items():
-        if hasattr(value, "_type"):
-            cls.handles[value._type] = value
-    return cls
-
-@register_class_handle
-class Client: 
-    __logger: Logger = logging.getLogger("simplyprint.client")
-    handles: Dict[Type[Event], Callable[[Event], Awaitable[None]]] = {}
-
-    config: Config = Config()
-    info: ClientInfo = ClientInfo()
-
-    local_path: Optional[str] = None
-    display_messages_enabled: bool = True
-
-    use_opencv: bool = False
-    use_ai: bool = False
-
-    connection: Connection = Connection(logging.getLogger("simplyprint.client.connection"))
-    printer: Printer = Printer()
-    intervals: Intervals = Intervals() 
-    ping_queue: List[float] = []
-
-    snapshot_endpoint: str = SNAPSHOT_ENDPOINT
-
-    loop: AsyncLoop = AsyncLoop()
-    process_task: Optional[Future] = None  
-
-    file_handler: Optional[FileHandler] = None
-
-    __been_offline: bool = False
-    __selected_file: Optional[str] = None
-    __webcam_connected: bool = False
-    __ai_scores: List[float] = []
-    __display_message_loop: Optional[Future] = None
-    __ambient_check: Optional[AmbientCheck] = None
-
-    # ---------- control ---------- # 
-    
-    # starts the client
-    # this initializes the connection and starts the event loop
-    # this should be called after the client has been configured
-    def start(self) -> None:
-        # start sentry
-        self.__start_sentry()
-
-        # set up FileHandler
-        if self.local_path is None:
-            raise Exception("local_path not set for Client")
-
-        self.file_handler = FileHandler(self, self.local_path)
-
-        # start AsyncLoop
-        self.loop.start() 
-
-        self.__initialize_webcam()
-        self.__initialize_connection()
-
-        ambient_check = AmbientCheck(self.__set_ambient)
-        self.__ambient_check = ambient_check
-        self.printer.status = PrinterStatus.OPERATIONAL
-        self.send_status()
-
-        self.process_task = self.loop.spawn(self.process_events())
-        self.loop.spawn(self.send_cpu_loop())
-        self.loop.spawn(self.send_ping_loop())
-        self.loop.spawn(self.__ambient_check.run_loop(self.printer))
-
-    def __initialize_webcam(self) -> None:
-        if self.use_opencv:
-            try:
-                import cv2
-            except ImportError:
-                self.__logger.warning("OpenCV not installed, webcam will not be used")
-                self.use_opencv = False
-                return None
-
-            self.__logger.info("using OpenCV for webcam")
-            self.__webcam = cv2.VideoCapture(-1)
-            self.__webcam_connected = self.__webcam.isOpened()
-
-    def __initialize_connection(self) -> None:
-        self.send_firmware()
-        self.send_machine_data()
-        self.send_webcam_connected() 
-
-    def __del__(self):
-        self.stop()
-    
-    # sets status to OFFLINE, stops the AsyncLoop
-    def stop(self) -> None:
-        self.status = PrinterStatus.OFFLINE
-        self.loop.stop() 
-
-    def __start_sentry(self):
-        if self.info.sentry_dsn is None:
-            return
-
-        self.__logger.debug("Initializing Sentry")
-
-        if self.info.development:
-            environment = "development"
-        else:
-            environment = "production"
-
-        try:
-            sentry_sdk.init(
-                dsn=self.info.sentry_dsn,
-                traces_sample_rate=0.05,
-                release=f"{self.info.client}@{self.info.client_version}",
-                environment=environment,
-            )
-            sentry_sdk.set_tag("lib_version", VERSION)
-            if self.config.id != "0":
-                sentry_sdk.set_user({"id": self.config.id})
-        except Exception:
-            self.__logger.debug("Failed to connect to sentry")
-
-    # ---------- info ---------- #
-
-    def __get_firmware(self) -> Dict[str, Any]:
-        return { "fw": self.printer.firmware.dict() }
-
-    # sends firmware to server, this is called at start
-    def send_firmware(self) -> None:
-        firmware = self.__get_firmware()
-        self.__logger.debug(f"Sending firmware: {firmware}")
-        self.send(PrinterEvent.FIRMWARE, firmware)
-
-    def __get_cpu(self) -> Dict[str, Any]:
-        temperature: Optional[float] = None
-        temperatures = psutil.sensors_temperatures()
-
-        # Find the first temperature sensor that is not None
-        # Priority is given in reverse order
-        temperature_keys = { "coretemp", "cpu-thermal", "cpu_thermal", "soc_thermal" } & set(temperatures.keys())
-        temperature = temperatures[temperature_keys.pop()][0].current if len(temperature_keys) > 0 is not None else 0
-    
-        return {
-            "usage": round(psutil.cpu_percent()),
-            "temp": round(temperature),
-            "memory": round(psutil.virtual_memory().percent),
-            "flags": 0,
-        }
-
-    # sends cpu to server, this is called at a fixed interval by send_cpu_loop
-    def send_cpu(self) -> None:
-        cpu = self.__get_cpu()
-        self.__logger.debug(f"Sending CPU info: {cpu}")
-        self.send(PrinterEvent.CPU, cpu)
-
-    def __get_machine_data(self) -> Dict[str, Any]:
-        return {
-            "ui": self.info.ui,
-            "ui_version": self.info.ui_version,
-            "api": self.info.api,
-            "api_version": self.info.api_version,
-            "sp_version": self.info.sp_version,
-            "python_version": self.info.python_version(),
-            "machine": self.info.machine(),
-            "os": self.info.os(),
-            "is_ethernet": self.info.is_ethernet(),
-            "ssid": self.info.ssid(),
-            "hostname": self.info.hostname(),
-            "local_ip": self.info.local_ip(),
-            "core_count": self.info.core_count(),
-            "total_memory": self.info.total_memory(),
-        }
-
-    # sends machine_data to server, this is called at start
-    def send_machine_data(self):
-        machine_data = self.__get_machine_data()
-        self.__logger.debug(f"Sending machine data: {machine_data}")
-        self.send(PrinterEvent.MACHINE_DATA, machine_data)
-
-    # ---------- client events ---------- #  
-
-    @property
-    def id(self) -> Optional[str]:
-        return self.config.id
-
-    @id.setter
-    def id(self, id: str) -> None:
-        self.config.id = id
-
-    @property
-    def token(self) -> Optional[str]:
-        return self.config.token
-    
-    @token.setter
-    def token(self, token: str) -> None:
-        self.config.token = token 
-
-    @property
-    def status(self) -> PrinterStatus:
-        return self.printer.status
-    
-    @status.setter
-    def status(self, status: PrinterStatus) -> None:
-        if self.printer.status == status:
-            return
-
-        self.__logger.debug(f"Status changed from {self.printer.status} to {status}")  
-
-        self.printer.status = status
-        
-        self.update_display_message()
-        self.send_status()
-
-    @property
-    def selected_file(self) -> Optional[str]:
-        return self.__selected_file
-
-    @property
-    def webcam_connected(self) -> bool:
-        return self.__webcam_connected
-
-    @webcam_connected.setter
-    def webcam_connected(self, connected: bool) -> None:
-        if self.__webcam_connected == connected:
-            return
-
-        self.__webcam_connected = connected
-        self.send_webcam_connected()
-
-    @property
-    def ambient_temperature(self) -> Optional[float]:
-        return self.printer.ambient_temperature
-
-    @ambient_temperature.setter
-    def ambient_temperature(self, temperature: float) -> None:
-        self.printer.ambient_temperature = temperature
-
-        if round(self.printer.ambient_temperature) != round(temperature):
-            self.send(PrinterEvent.AMBIENT, 
-                { "new": round(temperature) },
-            )
-
-    @property
-    def tool_temperatures(self) -> List[Temperature]:
-        return self.printer.tool_temperatures
-
-    @tool_temperatures.setter
-    def tool_temperatures(self, temperatures: List[Temperature]) -> None:
-        self.printer.tool_temperatures = temperatures
-
-        if self.intervals.temperatures_updating:
-            return
-
-        self.intervals.temperatures_updating = True
-        self.loop.spawn(self.send_temperatures())
-
-    @property
-    def bed_temperature(self) -> Optional[Temperature]:
-        return self.printer.bed_temperature
-    
-    @bed_temperature.setter
-    def bed_temperature(self, temperature: Temperature) -> None:
-        self.printer.bed_temperature = temperature
-
-        if self.intervals.temperatures_updating:
-            return
-
-        self.intervals.temperatures_updating = True
-        self.loop.spawn(self.send_temperatures())  
-    
-    # waits for the appropriate interval to pass before sending temperatures
-    async def send_temperatures(self) -> None:
-        if self.printer.is_heating():
-            await self.intervals.sleep_until_target_temperatures()
-        else:
-            await self.intervals.sleep_until_temperatures()
-
-        payload = {}
-
-        # TODO: this is virtually unreadable, clean it up
-        if not self.printer.bed_temperature is None:
-            if self.printer.server_bed_temperature is None: 
-                payload["bed"] = self.printer.bed_temperature.to_list()
-            else:
-                if self.printer.server_bed_temperature.to_list() != self.printer.bed_temperature.to_list():
-                    payload["bed"] = self.printer.bed_temperature.to_list()
-
-        for i, tool in enumerate(self.printer.tool_temperatures):
-            if i >= len(self.printer.server_tool_temperatures):
-                payload[f"tool{i}"] = self.printer.tool_temperatures[i].to_list()
-                continue
-
-            if tool.to_list() != self.printer.server_tool_temperatures[i].to_list():
-                payload[f"tool{i}"] = self.printer.tool_temperatures[i].to_list()
-
-        self.printer.server_bed_temperature = copy.deepcopy(self.printer.bed_temperature)
-        self.printer.server_tool_temperatures = copy.deepcopy(self.printer.tool_temperatures) 
-
-        if payload == {}:
-            self.intervals.temperatures_updating = False
-            return;
-
-        self.__logger.debug(f"Sending temperatures: {payload}")
-        await self.send_async(PrinterEvent.TEMPERATURES, payload)
-        self.intervals.temperatures_updating = False
-
-    # internal setter for ambient temperature
-    # used as callback for AmbientCheck
-    def __set_ambient(self, temperature: int) -> None:
-        self.ambient_temperature = float(temperature)
-
-        self.send(
-            PrinterEvent.AMBIENT, 
-            { "new": temperature },
-        )
-
-    # send webcam connected status
-    def send_webcam_connected(self):
-        self.__logger.debug(f"Sending webcam connected: {self.__webcam_connected}")
-        self.send(PrinterEvent.WEBCAM_STATUS, {
-            "connected": self.webcam_connected,
-        })
-
-    def send_status(self):
-        self.send(PrinterEvent.STATUS, {
-            "new": self.printer.status.value,
-        })
- 
-    # starts a print, this is usually called internally
-    def start_print(self): 
-        self.send_job_update({"started": True})
-
-        self.status = PrinterStatus.PRINTING
-        self.loop.spawn(self.handle_start_print_event(StartPrintEvent()))
-
-    # call this when the print is done
-    def print_done(self):
-        self.send_job_update({"finished": True})
-
-        self.status = PrinterStatus.OPERATIONAL
-
-    # sends a job update to the server
-    def send_job_update(self, job_info: Dict[str, Any]):
-        self.send(PrinterEvent.JOB_INFO, job_info)
-
-    # call this when the print is starts canceling
-    def cancel_print(self, error: str) -> None:
-        self.send_job_error(error)
-        self.status = PrinterStatus.CANCELLING
-        self.loop.spawn(self.handle_cancel_event(CancelEvent()))
-
-    # call this when the print is done canceling
-    def print_cancelled(self) -> None:
-        self.status = PrinterStatus.OPERATIONAL
-
-    # a loop that sends cpu updates
-    async def send_cpu_loop(self):
-        while True:
-            await self.intervals.sleep_until_cpu()
-            self.send_cpu()
-
-    def get_base64_url(self, url: str) -> Optional[str]:
-        headers = { "Accept": "image/jpeg" }
-        try:
-            response = requests.get(url, headers=headers, verify=False, timeout=2)
-        except Exception:
-            return None
-
-        return base64.b64encode(response.content).decode()
-
-    def set_display_message(self, message: str, short_branding: bool = False, spawn_loop: bool = True) -> None:
-        if not self.display_messages_enabled:
-            return
-
-        if self.printer.current_display_message == message:
-            return
-     
-        self.printer.current_display_message = message
-
-        event = DisplayMessageEvent(message, short_branding)
-        self.loop.spawn(self.handle_display_message_event(event))
-
-        if not spawn_loop:
-            return
-
-        if not self.__display_message_loop is None:
-            self.__display_message_loop.cancel()
-
-        self.__display_message_loop = self.loop.spawn(self.display_message_loop())
-
-    def update_display_message(self, spawn_loop: bool = True) -> None:
-        if not self.connection.is_connected() and not self.is_online():
-            self.set_display_message("No internet", spawn_loop=spawn_loop)
-            return
-
-        if self.status == PrinterStatus.OPERATIONAL:
-            self.set_display_message("Ready", spawn_loop=spawn_loop)
-            return
-
-        if self.status == PrinterStatus.PRINTING:
-            self.set_display_message(f"Printing '{self.selected_file}'", spawn_loop=spawn_loop)
-            return
-
-        if self.status == PrinterStatus.PAUSING:
-            self.set_display_message("Pausing", spawn_loop=spawn_loop)
-            return
-
-        if self.status == PrinterStatus.PAUSED:
-            self.set_display_message("Paused", spawn_loop=spawn_loop)
-            return
-
-        if self.status == PrinterStatus.CANCELLING:
-            self.set_display_message("Cancelling", spawn_loop=spawn_loop)
-            return
-
-    async def display_message_loop(self) -> None:
-        first = True
-        while True:
-            if first:
-                await asyncio.sleep(20.0)
-            else:
-                await asyncio.sleep(60.0)
-
-            if not self.printer.current_display_message is None:
-                self.update_display_message()
-
-            first = False
-
-    # ---------- io ---------- #
-
-    def send_job_error(self, error: str) -> None:
-        self.send(
-            PrinterEvent.JOB_INFO, 
-            { "error": error },
-        )
- 
-    # sends a PrinterEvent to the server
-    def send(self, event: PrinterEvent, data: Any) -> Future:
-        return self.loop.spawn(self.send_async(event, data))
- 
-    async def send_async(self, event: PrinterEvent, data: Any) -> None:
-        if data is None:
-            message = json.dumps({
-                "type": event.value,
-            })
-        else:
-            message = json.dumps({
-                "type": event.value,
-                "data": data,
-            })
-
-        await self.send_message_async(message)
-    
-    # spawns a new task to send the message
-    def send_message(self, message: str) -> None:
-        self.loop.spawn(self.send_message_async(message))
-
-    def is_online(self) -> bool:
-        try:
-            requests.get("https://www.google.com", timeout=2)
-            return True
-        except Exception:
-            self.__been_offline = True
-            return False
-    
-    # connect to the server, if it fails, will try again every reconnect interval FOR EVER
-    # TODO: this is a bit of a mess, clean it up
-    async def connect(self):
-        if not self.connection.is_connected() and not self.intervals.reconnect_updating and self.is_online():
-            self.set_display_message("Connecting...")
-
-        while not self.connection.is_connected():
-            if self.intervals.reconnect_updating:
-                # if we're already connecting, we don't need this read to trigger another connection
-                # but we still need to wait until we have connected.
-                # but i honestly can't figure out how to do it better than this
-                # TODO: fix pls
-                await asyncio.sleep(1.0)
-                continue
-
-
-            self.intervals.reconnect_updating = True
-
-            if not self.connection.reconnect_token is None:
-                await self.intervals.sleep_until_reconnect()
-            else:
-                await asyncio.sleep(1.0)
-
-            await self.connection.connect(self.config.id, self.config.token)
-
-            if self.connection.is_connected():
-                if self.__been_offline:
-                    self.__been_offline = False
-                    self.set_display_message("Back online!")
-                else:
-                    self.set_display_message("Connected")
-            elif not self.is_online():
-                self.set_display_message("No internet")
-            else:
-                self.set_display_message("Can't reach SP")
-
-            self.intervals.reconnect_updating = False
-
-    async def send_message_async(self, message: str) -> None:
-        await self.connect()
-        await self.connection.send_message(message)
-
-    # posts a snapshot to the server at self.snapshot_endpoint
-    async def post_snapshot(self, id: str, data: str) -> None:
-        headers = { "User-Agent": "Mozilla/5.0" }
-        await self.loop.aioloop.run_in_executor(
-            None,
-            functools.partial(
-                requests.post, 
-                self.snapshot_endpoint, 
-                data={
-                    "id": id,
-                    "data": data,
-                }, 
-                headers=headers, 
-                timeout=45.0,
-            )
-        )
-
-    # sends a snapshot to the server though the websocket
-    async def stream_snapshot(self, data: str) -> None:
-        self.__logger.debug("Streaming snapshot, sending")
-        await self.send_async(PrinterEvent.STREAM, {"base": data})
-
-    async def __make_ai_request(self, endpoint: str, data: str, headers: Dict[str, str], timeout: float = 10.0):
-        return await self.loop.aioloop.run_in_executor(
-            None,
-            functools.partial(
-                requests.post, endpoint, data=data, headers=headers,
-                timeout=timeout
-            )
-        )
-
-    async def __handle_ai_snapshot(self) -> bool:
-        snapshot = await self.on_webcam_snapshot(WebcamSnapshotEvent({}))
-        if snapshot is None:
-            self.__logger.error("Webcam snapshot was None")
-            return False
-
-        headers = { "User-Agent": "Mozilla/5.0" }
-        data = {
-            "api_key": self.config.token,     
-            "image_array": snapshot,
-            "interval": self.intervals.ai,
-            "printer_id": self.config.id,
-            "settings": {
-                "buffer_percent": 80.0,
-                "confidence": 60.0,
-                "buffer_length": 16.0,
-            },
-            "scores": self.__ai_scores,
-        }
-        json_data = json.dumps(data)
-
-        try:
-            response = await self.__make_ai_request(AI_ENDPOINT, json_data, headers)
-        except Exception:
-            return False
-
-        response_json = response.json()
-        self.__ai_scores = response_json.get("scores", self.__ai_scores)
-        self.send(
-            PrinterEvent.AI_RESPONSE, 
-            {
-                "ai": response_json.get("s1", [0.0, 0.0, 0.0]),
-            }
-        )
-
-        return True
-
-    def __should_run_ai(self) -> bool:
-        return self.intervals.ai > 0.0 and self.webcam_connected and self.status == PrinterStatus.PRINTING
-
-    async def ai_loop(self):
-        failed_ai_attempts = 120.0
-        delay = self.intervals.ai
-
-        while self.__should_run_ai():
-            await asyncio.sleep(delay)
-
-            if await self.__handle_ai_snapshot():
-                failed_ai_attempts = 0.0
-                delay = self.intervals.ai
-            else:
-                failed_ai_attempts += 1.0
-                delay = self.intervals.ai + (failed_ai_attempts * 5.0) if failed_ai_attempts < 10.0 else 120.0
-
-    # ---------- server events ---------- #
-
-    # a loop that sends pings to the server 
-    async def send_ping_loop(self) -> None:
-        while True:
-            await self.intervals.sleep_until_ping()
-            await self.send_async(PrinterEvent.PING, None)
-            self.ping_queue.append(time.time() * 1000.0)
-    
-    # receives events in a loop and spawns the appropriate handlers
-    async def process_events(self):
-        while True:
-            await self.connect()
-
-            event = await self.connection.read_event()
-
-            if event is None:
-                self.__logger.error("invalid event")
-                continue
-
-            self.loop.spawn(self.handle_event(event))
-
-    # handles a single event
-    async def handle_event(self, event: Event) -> None:
-        await self.on_event(event)
-
-        handler = self.handles.get(type(event), None)
-
-        if handler is None:
-            self.__logger.error(f"no handler for event {event}")
-            return
-        
-        try:
-            await handler(self, event)
-        except Exception as e:
-            self.__logger.error(f"error handling event {event}: {e}")
-            await self.on_error(ErrorEvent(e))
-     
-    # ---------- events ---------- #
-
-    async def on_event(self, _: Event) -> None:
-        pass
-
-    async def on_error(self, _: ErrorEvent) -> None:
-        pass
-
-    async def on_new_token(self, _: NewTokenEvent) -> None:
-        pass
-
-    async def on_connect(self, _: ConnectEvent) -> None:
-        pass
-    
-    async def on_setup_complete(self, _: SetupCompleteEvent) -> None:
-        pass
-
-    async def on_interval_change(self, _: IntervalChangeEvent) -> None:
-        pass
-
-    async def on_pong(self, _: PongEvent) -> None:
-        pass
-
-    async def on_stream_received(self, _: StreamReceivedEvent) -> None:
-        pass
-
-    async def on_printer_settings(self, _: PrinterSettingsEvent) -> None:
-        pass
-
-    async def on_pause(self, _: PauseEvent) -> None:
-        pass
-
-    async def on_resume(self, _: ResumeEvent) -> None:
-        pass
-
-    async def on_cancel(self, _: CancelEvent) -> None:
-        self.print_cancelled()
-
-    async def on_terminal(self, _: TerminalEvent) -> None:
-        pass
-
-    async def on_display_message(self, event: DisplayMessageEvent) -> None:
-        message = event.message
-
-        if self.printer.settings.display.branding:
-            if event.short_branding or len(message) > 7:
-                message = f"[SP] {message}"
-            else:
-                message = f"[SimplyPrint] {message}"
-
-        gcode_event = GcodeEvent();
-        gcode_event.list = [f"M117 {event.message}"]
-
-        await self.handle_gcode_event(gcode_event)
-
-    async def on_gcode(self, _: GcodeEvent) -> None:
-        pass
-
-    async def on_webcam_test(self, _: WebcamTestEvent) -> None:
-        self.__initialize_webcam()
-
-    async def on_webcam_snapshot(self, _: WebcamSnapshotEvent) -> Optional[str]:
-        if not self.use_opencv:
-            self.__logger.warn("Webcam snapshot requested but OpenCV is not used and event is not overridden")
-
-            return None
-
-        if not hasattr(self, "_Client__webcam"):
-            return None
-
-        try:
-            import cv2
-        except ImportError:
-            self.__logger.warn("OpenCV is not installed but is required for webcam snapshots")
-            return None
-
-        MAX_WIDTH = 1280
-        MAX_HEIGHT = 720
-
-        _, frame = self.__webcam.read() 
-        aspect = frame.shape[1] / frame.shape[0]
-
-        if frame.shape[0] > MAX_HEIGHT:
-            frame = cv2.resize(frame, (int(MAX_HEIGHT * aspect), MAX_HEIGHT))
-
-        if frame.shape[1] > MAX_HEIGHT * aspect:
-            frame = cv2.resize(frame, (MAX_WIDTH, int(MAX_HEIGHT / aspect)))
-
-        _, buffer = cv2.imencode(".jpg", frame)
-        img_bytes = buffer.tobytes();
-        data = base64.b64encode(img_bytes).decode()
-
-        return data
-
-    async def on_file(self, _: FileEvent) -> None:
-        pass
-
-    async def on_start_print(self, _: StartPrintEvent) -> None:
-        pass
-
-    async def on_connect_printer(self, _: ConnectPrinterEvent) -> None:
-        pass
-
-    async def on_disconnect_printer(self, _: DisconnectPrinterEvent) -> None:
-        pass
-
-    async def on_system_restart(self, _: SystemRestartEvent) -> None:
-        if platform.system() == "Linux":
-            os.system("reboot")
-        elif platform.system() == "Windows":
-            os.system("shutdown /r")
-
-    async def on_system_shutdown(self, _: SystemShutdownEvent) -> None:
-        if platform.system() == "Linux":
-            os.system("shutdown now")
-        elif platform.system() == "Windows":
-            os.system("shutdown /s")
-
-    async def on_api_restart(self, _: ApiRestartEvent) -> None:
-        pass
-
-    async def on_api_shutdown(self, _: ApiShutdownEvent) -> None:
-        pass
-
-    async def on_update(self, _: UpdateEvent) -> None:
-        pass
-
-    async def on_plugin_install(self, _: PluginInstallEvent) -> None:
-        pass
-
-    async def on_plugin_uninstall(self, _: PluginUninstallEvent) -> None:
-        pass
-
-    async def on_webcam_settings(self, _: WebcamSettingsEvent) -> None:
-        pass
-
-    async def on_stream_on(self, _: StreamOnEvent) -> None:
-        pass
-
-    async def on_stream_off(self, _: StreamOffEvent) -> None:
-        pass
-
-    async def on_set_printer_profile(self, _: SetPrinterProfileEvent) -> None:
-        pass
-
-    async def on_get_gcode_script_backups(self, _: GetGcodeScriptBackupsEvent) -> None:
-        pass
-
-    async def on_has_gcode_changes(self, _: HasGcodeChangesEvent) -> None:
-        pass
-
-    async def on_psu_control(self, _: PsuControlEvent) -> None:
-        pass
-
-    async def on_disable_websocket(self, _: DisableWebsocketEvent) -> None:
-        pass
-
-    # ---------- event handlers ---------- #
-    
-    @register_handle(ErrorEvent)
-    async def handle_error_event(self, event: ErrorEvent) -> None:
-        self.__logger.error(f"Error: {event.error}")
-
-        self.intervals.reconnect = 30.0
-        self.connection.reconnect_token = None
-
-        await self.on_error(event)
-    
-    @register_handle(NewTokenEvent)
-    async def handle_new_token_event(self, event: NewTokenEvent) -> None:
-        self.__logger.info(f"Received new token: {event.token} short id: {event.short_id}")
-
-        self.config.token = event.token
-
-        self.config.save()
-
-        self.set_display_message(f"{event.short_id}")
-
-        if event.no_exist:
-            self.printer.is_set_up = False
-
-        await self.on_new_token(event)
-    
-    @register_handle(ConnectEvent)
-    async def handle_connect_event(self, event: ConnectEvent) -> None:
-        self.__logger.info(f"Connected to server")
-
-        self.intervals.update(event.intervals)
-        self.printer.connected = True
-        self.printer.name = event.name
-        self.connection.reconnect_token = event.reconnect_token
-
-        if event.in_set_up:
-            self.printer.is_set_up = False
-
-        await self.on_connect(event)
-    
-    @register_handle(SetupCompleteEvent)
-    async def handle_setup_complete_event(self, event: SetupCompleteEvent) -> None:
-        self.__logger.info(f"Setup complete")
-
-        self.config.id = event.printer_id
-        self.printer.is_set_up = True
-
-        sentry_sdk.set_user({"id": event.printer_id})
-
-        self.config.save()
-
-        self.set_display_message("Setup complete")
-
-        await self.on_setup_complete(event)
-    
-    @register_handle(IntervalChangeEvent)
-    async def handle_interval_change_event(self, event: IntervalChangeEvent) -> None:
-        self.__logger.info(f"Interval change")
-
-        self.intervals.update(event.intervals)
-        await self.on_interval_change(event)
-    
-    @register_handle(PongEvent)
-    async def handle_pong_event(self, event: PongEvent) -> None:
-        self.__logger.info(f"Pong")
-
-        if len(self.ping_queue) > 0:
-            ping = self.ping_queue.pop(0)
-            latency = time.time() * 1000.0 - ping
-            self.send(PrinterEvent.LATENCY, {"ms": round(latency)})
-        else:
-            self.__logger.error(f"Received pong without ping")
-
-
-        await self.on_pong(event)
-    
-    @register_handle(StreamReceivedEvent)
-    async def handle_stream_received_event(self, event: StreamReceivedEvent) -> None:
-        self.__logger.info(f"Stream received")
-
-        await self.on_stream_received(event)
-    
-    @register_handle(PrinterSettingsEvent)
-    async def handle_printer_settings_event(self, event: PrinterSettingsEvent) -> None:
-        self.__logger.info(f"Printer settings")
-
-        await self.on_printer_settings(event)
-    
-    @register_handle(PauseEvent)
-    async def handle_pause_event(self, event: PauseEvent) -> None:
-        self.__logger.info(f"Pause")
-
-        await self.on_pause(event)
-    
-    @register_handle(ResumeEvent)
-    async def handle_resume_event(self, event: ResumeEvent) -> None:
-        self.__logger.info(f"Resume")
-
-        await self.on_resume(event)
-    
-    @register_handle(CancelEvent)
-    async def handle_cancel_event(self, event: CancelEvent) -> None:
-        self.__logger.info(f"Cancel Print")
-
-        self.status = PrinterStatus.CANCELLING
-
-        await self.on_cancel(event)
-    
-    @register_handle(TerminalEvent)
-    async def handle_terminal_event(self, event: TerminalEvent) -> None:
-        self.__logger.info(f"Terminal")
-
-        await self.on_terminal(event)
-
-    @register_handle(DisplayMessageEvent)
-    async def handle_display_message_event(self, event: DisplayMessageEvent) -> None:
-        self.__logger.info(f"Display message")
-
-        await self.on_display_message(event)
-    
-    @register_handle(GcodeEvent)
-    async def handle_gcode_event(self, event: GcodeEvent) -> None:
-        self.__logger.info(f"Gcode")
-
-        await self.on_gcode(event)
-    
-    @register_handle(WebcamTestEvent)
-    async def handle_webcam_test_event(self, event: WebcamTestEvent) -> None:
-        self.__logger.info(f"Webcam test")
-
-        self.send_webcam_connected()
-        await self.on_webcam_test(event)
-    
-    @register_handle(WebcamSnapshotEvent)
-    async def handle_webcam_snapshot_event(self, event: WebcamSnapshotEvent) -> None:
-        self.__logger.info(f"Webcam snapshot")
-
-        if not event.timer is None:
-            await asyncio.sleep(event.timer  / 1000.0)
-
-        if not self.webcam_connected:
-            return
-        
-        snapshot = await self.on_webcam_snapshot(event)
-
-        if not snapshot is None:
-            if not event.id is None:
-                await self.post_snapshot(event.id, snapshot)
-            else:
-                await self.stream_snapshot(snapshot)
-        else:
-            self.__logger.error(f"Snapshot was requested but None was returned")
-    
-    @register_handle(FileEvent)
-    async def handle_file_event(self, event: FileEvent) -> None:
-        self.__logger.info(f"File")
-
-        if self.file_handler is None:
-            raise Exception("Client not started")
-
-        if not event.url is None:
-            self.__selected_file = self.file_handler.download(event.url)
-            event.path = self.__selected_file
-            self.set_display_message("Starting...")
-        
-        if not event.path is None:
-            self.__selected_file = event.path
-
-        if event.auto_start:
-            self.start_print()
-
-        await self.on_file(event)
-    
-    @register_handle(StartPrintEvent)
-    async def handle_start_print_event(self, event: StartPrintEvent) -> None:
-        self.__logger.info(f"Start print")
-        
-        if self.use_ai:
-            self.loop.spawn(self.ai_loop())
-        
-        await self.on_start_print(event)
-    
-    @register_handle(ConnectPrinterEvent)
-    async def handle_connect_printer_event(self, event: ConnectPrinterEvent) -> None:
-        self.__logger.info(f"Connect printer")
-
-        await self.on_connect_printer(event)
-    
-    @register_handle(DisconnectPrinterEvent)
-    async def handle_disconnect_printer_event(self, event: DisconnectPrinterEvent) -> None:
-        self.__logger.info(f"Disconnect printer")
-
-        await self.on_disconnect_printer(event)
-    
-    @register_handle(SystemRestartEvent)
-    async def handle_system_restart_event(self, event: SystemRestartEvent) -> None:
-        self.__logger.info(f"System restart")
-
-        await self.on_system_restart(event)
-    
-    @register_handle(SystemShutdownEvent)
-    async def handle_system_shutdown_event(self, event: SystemShutdownEvent) -> None:
-        self.__logger.info(f"System shutdown")
-
-        await self.on_system_shutdown(event)
-
-    @register_handle(ApiRestartEvent)
-    async def handle_api_restart_event(self, event: ApiRestartEvent) -> None:
-        self.__logger.info(f"API restart")
-
-        await self.on_api_restart(event)
-    
-    @register_handle(ApiShutdownEvent)
-    async def handle_api_shutdown_event(self, event: ApiShutdownEvent) -> None:
-        self.__logger.info(f"API shutdown")
-
-        await self.on_api_shutdown(event)
-    
-    @register_handle(UpdateEvent)
-    async def handle_update_event(self, event: UpdateEvent) -> None:
-        self.__logger.info(f"Update")
-
-        await self.on_update(event)
-
-    @register_handle(PluginInstallEvent)    
-    async def handle_plugin_install_event(self, event: PluginInstallEvent) -> None:
-        self.__logger.info(f"Plugin install")
-
-        await self.on_plugin_install(event)
-    
-    @register_handle(PluginUninstallEvent)
-    async def handle_plugin_uninstall_event(self, event: PluginUninstallEvent) -> None:
-        self.__logger.info(f"Plugin uninstall")
-
-        await self.on_plugin_uninstall(event)
-    
-    @register_handle(WebcamSettingsEvent)
-    async def handle_webcam_settings_event(self, event: WebcamSettingsEvent) -> None:
-        self.__logger.info(f"Webcam settings")
-
-        await self.on_webcam_settings(event)
-    
-    @register_handle(StreamOnEvent)
-    async def handle_stream_on_event(self, event: StreamOnEvent) -> None:
-        self.__logger.info(f"Stream on")
-
-        await self.on_stream_on(event)
-    
-    @register_handle(StreamOffEvent)
-    async def handle_stream_off_event(self, event: StreamOffEvent) -> None:
-        self.__logger.info(f"Stream off")
-
-        await self.on_stream_off(event)
-    
-    @register_handle(SetPrinterProfileEvent)
-    async def handle_set_printer_profile_event(self, event: SetPrinterProfileEvent) -> None:
-        self.__logger.info(f"Set printer profile")
-
-        await self.on_set_printer_profile(event)
-    
-    @register_handle(GetGcodeScriptBackupsEvent)
-    async def handle_get_gcode_script_backups_event(self, event: GetGcodeScriptBackupsEvent) -> None:
-        self.__logger.info(f"Get gcode script backups")
-
-        await self.on_get_gcode_script_backups(event)
-    
-    @register_handle(HasGcodeChangesEvent)
-    async def handle_has_gcode_changes_event(self, event: HasGcodeChangesEvent) -> None:
-        self.__logger.info(f"Has gcode changes")
-
-        await self.on_has_gcode_changes(event)
-    
-    @register_handle(PsuControlEvent)
-    async def handle_psu_control_event(self, event: PsuControlEvent) -> None:
-        self.__logger.info(f"PSU control")
-
-        await self.on_psu_control(event)
-    
-    @register_handle(DisableWebsocketEvent)
-    async def handle_disable_websocket_event(self, event: DisableWebsocketEvent) -> None:
-        self.__logger.info(f"Disable websocket")
-
-        await self.on_disable_websocket(event)
-
diff --git a/simplyprint_ws_client/config.py b/simplyprint_ws_client/config.py
deleted file mode 100644
index aa732ce..0000000
--- a/simplyprint_ws_client/config.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import json
-import os
-
-from simplyprint_ws_client.const import CONFIG_FILE_PATH
-
-class Config:
-    id: str
-    token: str
-
-    def __init__(self):
-        if os.path.exists(CONFIG_FILE_PATH):
-            self.__dict__ = json.load(open(CONFIG_FILE_PATH))
-
-            if self.id is None:
-                self.id: str = "0"
-
-            if self.token is None:
-                self.token: str = "0"
-        else:
-            self.id: str = "0"
-            self.token: str = "0"
-
-    def save(self):
-        with open(CONFIG_FILE_PATH, "w") as f:
-            json.dump(self.__dict__, f, indent=4)
diff --git a/simplyprint_ws_client/config/__init__.py b/simplyprint_ws_client/config/__init__.py
new file mode 100644
index 0000000..ee82c2b
--- /dev/null
+++ b/simplyprint_ws_client/config/__init__.py
@@ -0,0 +1,24 @@
+from enum import Enum
+from typing import Type
+
+from .config import Config
+from .json import JsonConfigManager
+from .manager import ConfigManager
+from .memory import MemoryConfigManager
+from .sqlite3 import SqliteConfigManager
+
+
+class ConfigManagerType(Enum):
+    MEMORY = "memory"
+    SQLITE = "sqlite"
+    JSON = "json"
+
+    def get_class(self) -> Type[ConfigManager]:
+        if self == ConfigManagerType.MEMORY:
+            return MemoryConfigManager
+        elif self == ConfigManagerType.SQLITE:
+            return SqliteConfigManager
+        elif self == ConfigManagerType.JSON:
+            return JsonConfigManager
+        else:
+            raise ValueError("Invalid ConfigManagerType")
\ No newline at end of file
diff --git a/simplyprint_ws_client/config/config.py b/simplyprint_ws_client/config/config.py
new file mode 100644
index 0000000..35f7d7f
--- /dev/null
+++ b/simplyprint_ws_client/config/config.py
@@ -0,0 +1,92 @@
+from typing import Optional
+
+class ConfigMeta(type):
+    """ 
+    Automatically add correct slots based on
+    type annotations for config definitions.
+    """
+        
+    def __new__(mcs, name, bases, ns):
+        slots = set(ns.get('__slots__', ()))
+
+        # Traverse parent classes and add their slots
+        for base in bases:
+            if hasattr(base, '__slots__'):
+                slots |= set(base.__slots__)
+
+        annotations = ns.get('__annotations__', {})
+        slots |= set(annotations.keys())
+        
+        ns['__slots__'] = slots
+        return super().__new__(mcs, name, bases, ns)
+    
+class Config(metaclass=ConfigMeta):
+    """
+    Configuration object.
+    """
+    
+    id: int
+    token: str
+    name: Optional[str]
+    in_setup: Optional[bool]
+
+    public_ip: Optional[str]
+
+    unique_id: Optional[str]
+
+    def is_pending(self) -> bool:
+        return self.id == 0
+    
+    def is_blank(self) -> bool:
+        # Check if any other slots are filled
+        is_blank = True
+
+        for slot in self.__slots__:
+            if not hasattr(self, slot):
+                continue
+
+            if slot in ["id", "token"]:
+                continue
+
+            if getattr(self, slot) is not None:
+                is_blank = False
+                break
+
+        return self.is_pending() and len(self.token) < 2 and is_blank
+
+    def __init__(self, **kwargs) -> None:            
+        for key in kwargs:
+            setattr(self, key, kwargs[key])
+    
+    def as_dict(self) -> dict:
+        return dict(sorted([ (slot, getattr(self, slot)) for slot in self.__slots__ if hasattr(self, slot) ], key=lambda x: x[0]))
+    
+    def get_unique_id(self) -> Optional[str]:
+        return str(id(self)) if not hasattr(self, "unique_id") else self.unique_id  
+
+    def __repr__(self) -> str:
+        return str(self)
+    
+    def __str__(self)  -> str:    
+        return f"<Config {self.as_dict()}'>"
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, int): return self.id == other
+        if isinstance(other, Config): return id(self) == id(other)
+        return False
+    
+    def partial_eq(self, other: 'Config') -> bool:
+        # Loop over all slots in other
+        for slot in other.__slots__:
+            if not hasattr(other, slot):
+                continue
+
+            # If the slot is not in self, or the values are not equal
+            if not hasattr(self, slot) or getattr(self, slot) != getattr(other, slot):
+                return False
+
+        return True
+
+    @staticmethod
+    def get_blank() -> 'Config':
+        return Config(id=0, token="0")
diff --git a/simplyprint_ws_client/config/json.py b/simplyprint_ws_client/config/json.py
new file mode 100644
index 0000000..e60d7a3
--- /dev/null
+++ b/simplyprint_ws_client/config/json.py
@@ -0,0 +1,42 @@
+import json
+from pathlib import Path
+from typing import Optional
+
+from simplyprint_ws_client.config.config import Config
+
+from .manager import ConfigManager
+
+
+class JsonConfigManager(ConfigManager):
+    def flush(self, config: Optional[Config] = None):
+        self._ensure_json_file()
+
+        with open(self._json_file, "w") as file:
+            data = [ config.as_dict() for config in self.configurations.values() if not config.is_blank() ]
+            json.dump(data, file, indent=4)
+
+    def load(self):
+        self._ensure_json_file()
+
+        with open(self._json_file, "r") as file:
+            data = json.load(file)
+
+            for config in data:
+                self.persist(self.config_t(**config))
+
+    def deleteStorage(self):
+        if not self._json_file.exists():
+            return
+        
+        self._json_file.unlink()
+
+    @property
+    def _json_file(self) -> Path:
+        return self.base_directory / f"{self.name}.json"
+
+    def _ensure_json_file(self):
+        if not self._json_file.exists():
+            self._json_file.touch()
+
+            with open(self._json_file, "w") as file:
+                json.dump([], file, indent=4)
\ No newline at end of file
diff --git a/simplyprint_ws_client/config/manager.py b/simplyprint_ws_client/config/manager.py
new file mode 100644
index 0000000..b5687be
--- /dev/null
+++ b/simplyprint_ws_client/config/manager.py
@@ -0,0 +1,90 @@
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Dict, Generic, List, Optional, Type, TypeVar
+
+from ..const import APP_DIRS
+from .config import Config
+
+TConfig = TypeVar("TConfig", bound=Config)
+
+class ConfigManager(ABC, Generic[TConfig]):
+    name: str
+    config_t: Type[Config]
+    configurations: Dict[int, Config]
+    base_directory: Path
+
+    def __init__(self, name: str = "printers", config_t: Type[Config] = Config, base_directory: Optional[str] = None) -> None:
+        self.name = name
+        self.config_t = config_t
+        self.configurations = {}
+
+        # Default to user config directory if not specified
+        self.base_directory = Path(base_directory or APP_DIRS.user_config_dir)
+
+        if not self.base_directory.exists():
+            self.base_directory.mkdir(parents=True)
+
+        # Read all configurations from storage initially.
+        self.load()
+
+    def by_id(self, id: int) -> Config:
+        return self.by_other(self.config_t(id=id))
+
+    def by_token(self, token: str) -> Config:
+        return self.by_other(self.config_t(token=token))
+
+    def by_other(self, other: Config) -> Config:
+        for config in self.configurations.values():
+            if config.partial_eq(other):
+                return config
+            
+        return None
+
+    def contains(self, other: Config) -> Config:
+        return id(other) in self.configurations
+
+    def persist(self, config: Config):
+        if self.contains(config):
+            return
+                
+        self.configurations[id(config)] = config
+
+    def remove(self, config: Config):
+        if not self.contains(config):
+            return
+        
+        del self.configurations[id(config)]
+
+    def get_all(self) -> List[Config]:
+        return list(self.configurations.values())
+    
+    def clear(self):
+        self.configurations.clear()
+
+    @abstractmethod
+    def flush(self, config: Optional[Config] = None):
+        """
+        Tell the manager to save all configs.
+
+        Optionally if the manager supports it, only update a single config.
+        """
+        ...
+
+    @abstractmethod
+    def load(self):
+        """
+        Tell the manager to load all configs.
+
+        The manager can decide whether to override existing configs or not.
+        """
+        ...
+
+    @abstractmethod
+    def deleteStorage(self):
+        """
+        Delete the entire storage.
+        """
+        ...
+
+    def __len__(self):
+        return len(self.configurations)
\ No newline at end of file
diff --git a/simplyprint_ws_client/config/memory.py b/simplyprint_ws_client/config/memory.py
new file mode 100644
index 0000000..462b73f
--- /dev/null
+++ b/simplyprint_ws_client/config/memory.py
@@ -0,0 +1,13 @@
+from typing import Optional
+from simplyprint_ws_client.config.config import Config
+from .manager import ConfigManager
+
+class MemoryConfigManager(ConfigManager):
+    def flush(self, config: Optional[Config] = None):
+        ...
+    
+    def load(self):
+        ...
+    
+    def deleteStorage(self):
+        ...
\ No newline at end of file
diff --git a/simplyprint_ws_client/config/sqlite3.py b/simplyprint_ws_client/config/sqlite3.py
new file mode 100644
index 0000000..c3daa3c
--- /dev/null
+++ b/simplyprint_ws_client/config/sqlite3.py
@@ -0,0 +1,130 @@
+import json
+import logging
+from pathlib import Path
+import sqlite3
+from typing import Optional
+
+from simplyprint_ws_client.config.config import Config
+
+from .manager import ConfigManager
+
+class SqliteConfigManager(ConfigManager):
+    """
+    Handles configuration state and persistence.
+    """
+
+    logger: logging.Logger = logging.getLogger("config.sqlite3")
+
+    db: Optional[sqlite3.Connection] = None
+    table_exists: bool = False
+
+    def flush(self, config: Optional[Config] = None):
+        self._ensure_table()
+
+        try:
+            if config is not None:
+                self._flush_single(config)
+                self._remove_detached()
+                return
+            
+            for config in self.configurations.values():
+                # Do not flush blank configs
+                if config.is_blank():
+                    continue
+
+                self._flush_single(config)
+            
+            self._remove_detached()
+        finally:
+            self.db.commit()
+
+    def load(self):
+        self._ensure_table()
+
+        configs = self.db.execute(
+            """
+            SELECT id, token, data FROM printers
+            """).fetchall()
+
+        for config in configs:
+            kwargs = json.loads(config[2])
+            kwargs["id"] = config[0]
+            kwargs["token"] = config[1]
+            self.persist(self.config_t(**kwargs))
+    
+    def deleteStorage(self):
+        if not self._database_file.exists():
+            return
+        
+        self._database_file.unlink()
+    
+    def _get_single(self, config: Config):
+        return self.db.execute(
+            """
+            SELECT id, token FROM printers WHERE id= ? AND token= ? LIMIT 1
+            """, (config.id, config.token)).fetchone()
+
+    def _flush_single(self, config: Config):
+        # Check if unique token and id exists
+        already_exists = self._get_single(config)
+
+        if not already_exists:
+            self.db.execute(
+                """
+                INSERT INTO printers (id, token, data) VALUES (?, ?, ?)
+                """, (config.id, config.token, json.dumps(config.as_dict())))
+
+            self.db.commit()
+            self.logger.info(f"Inserted config {config}")
+            return
+        
+        self.db.execute(
+            """
+            UPDATE printers SET data= ? WHERE id= ? AND token= ?
+            """, (json.dumps(config.as_dict()), config.id, config.token))
+
+    def _remove_detached(self):
+        # Get all configs from the database
+        configs = self.db.execute(
+            """
+            SELECT id, token FROM printers
+            """).fetchall()
+        
+        # Loop over all configs
+        for config in configs:
+            # If the config is not in the manager
+            if not self.by_other(self.config_t(id=config[0], token=config[1])):
+                # Remove it from the database
+                self.db.execute(
+                    """
+                    DELETE FROM printers WHERE id= ? AND token= ?
+                    """, (config[0], config[1]))
+
+    @property
+    def _database_file(self) -> Path:
+        return self.base_directory / f"{self.name}.db"
+
+    def _ensure_table(self):
+        """
+        Ensure the config table exists.
+        """
+
+        if not SqliteConfigManager.db or not self._database_file.exists():
+            SqliteConfigManager.db = sqlite3.connect(self._database_file.as_posix(), check_same_thread=False)
+
+        if SqliteConfigManager.table_exists:
+            return
+
+        self.db.execute(
+            """
+            CREATE TABLE IF NOT EXISTS printers (
+                id INTEGER, 
+                token TEXT, 
+                data TEXT, 
+                PRIMARY KEY (id, token)
+            );
+            """)
+        
+        self.db.commit()
+        self.logger.info("Created printers table")
+        SqliteConfigManager.table_exists = True
\ No newline at end of file
diff --git a/simplyprint_ws_client/connection.py b/simplyprint_ws_client/connection.py
index fa2d4da..388fe2e 100644
--- a/simplyprint_ws_client/connection.py
+++ b/simplyprint_ws_client/connection.py
@@ -1,156 +1,171 @@
 import json
-import requests
+import logging
+import threading
+from asyncio import AbstractEventLoop, CancelledError
+from typing import Any, Dict, Optional, Union
 
-from tornado.websocket import WebSocketClientConnection, WebSocketClosedError, websocket_connect
-from logging import Logger
+from aiohttp import (ClientConnectorError, ClientSession,
+                     ClientWebSocketResponse, WSMsgType,
+                     WSServerHandshakeError)
 
-from simplyprint_ws_client.const import REACHABLE_URL, WEBSOCKET_URL
+from .events import DemandEvent, ServerEvent, get_event
+from .events.client_events import ClientEvent, ClientEventMode
+from .events.event import Event
+from .events.event_bus import EventBus
 
-from .event import *
 
-from typing import (
-    Optional, 
-    Dict, 
-    Any, 
-)
+class ConnectionEventReceivedEvent(Event):
+    event: Union[ServerEvent, DemandEvent]
+    for_client: Optional[int] = None
+    
+    def __init__(self, event: Union[ServerEvent, DemandEvent], for_client: Optional[int] = None) -> None:
+        self.event = event
+        self.for_client = for_client
 
-class Connection:
-    def __init__(self, logger: Logger) -> None:
-        self.ws: Optional[WebSocketClientConnection] = None
+class ConnectionConnectedEvent(Event):
+    ...
 
-        self.api_version: str = "0.1"
-        self.reconnect_token: Optional[str] = None
+class ConnectionDisconnectEvent(Event):
+    ...
 
-        self.log_connect: bool = True
-        self.logger: Logger = logger
+class ConnectionReconnectEvent(Event):
+    ...
 
-    def is_connected(self) -> bool:
-        return self.ws is not None
+class ConnectionEventBus(EventBus[Event]):
+    ...
 
-    def get_url(self, id: str, token: str) -> str:
-        return f"{WEBSOCKET_URL}/{self.api_version}/p/{id}/{token}"
 
-    async def connect(self, id: str, token: str) -> None:
-        url = self.get_url(id, token)
+class Connection:
+    logger = logging.getLogger("websocket")
 
-        if self.reconnect_token is not None:
-            url = f"{url}/{self.reconnect_token}"
+    event_bus: ConnectionEventBus
 
-        if self.log_connect:
-            self.logger.info(f"Connecting to {url}")
+    loop: AbstractEventLoop
+    socket: Optional[ClientWebSocketResponse] = None
+    session: Optional[ClientSession] = None
 
-        try:
-            requests.get(REACHABLE_URL, timeout=5.0)
-        except Exception:
-            return
+    # Ensure only a single thread can connect at a time
+    _connection_lock: threading.Lock = threading.Lock()
 
-        self.ws = await websocket_connect(url, connect_timeout=5.0)
+    url: Optional[str] = None
+    timeout: float = 5.0
 
-    def _log_disconnect(self) -> None:
-        if self.ws is None:
-            return
+    def __init__(self, loop: AbstractEventLoop) -> None:
+        self.loop = loop
+        self.event_bus = ConnectionEventBus()
 
-        reason = self.ws.close_reason
-        code = self.ws.close_code
+    def set_url(self, url: str) -> None:
+        self.url = url
 
-        msg = (
-            f"SimplyPrint Disconnected - Code: {code} Reason: {reason}"
-        ) 
+    async def connect(self, url: Optional[str] = None, session: Optional[ClientSession] = None, timeout: Optional[float] = None) -> None:
+        with self._connection_lock:
+            reconnected = False
 
-        self.logger.info(msg)
+            if self.socket:
+                await self.socket.close()
+                self.socket = None
+                reconnected = True
 
-    async def send_message(self, message: str) -> None:
-        if self.ws is None:
-            raise Exception("not connected")
+            self.url = url or self.url
+            self.timeout = timeout or self.timeout
+            self.session = self.session or session or ClientSession(loop=self.loop)
 
-        try:
-            fut = self.ws.write_message(message);
-        except WebSocketClosedError:
-            self._log_disconnect()
+            if not self.url:
+                raise ValueError("No url specified")
 
-            self.ws = None
-            return
+            try:
+                self.socket = await self.session.ws_connect(self.url, timeout=timeout, autoclose=False, max_msg_size=0, compress=False)
+            except WSServerHandshakeError as e:
+                self.logger.error(f"Failed to connect to {self.url} with status code {e.status}")
+                return
+            except ClientConnectorError:
+                self.logger.error(f"Failed to connect to {self.url}")
+                return
 
-        await fut
+            if reconnected:
+                await self.event_bus.emit(ConnectionReconnectEvent())
+                self.logger.debug(f"Reconnected to {self.url}")
+            else:
+                await self.event_bus.emit(ConnectionConnectedEvent())
+                self.logger.debug(f"Connected to {self.url}")
 
-    
-    async def read_message(self) -> Optional[str]:
-        if self.ws is None:
-            raise Exception("not connected")
+    async def close(self) -> None:
+        if self.socket:
+            await self.socket.close()
+            self.socket = None
 
-        message = await self.ws.read_message()
+        if self.session:
+            await self.session.close()
+            self.session = None
 
-        if message is None: 
-            self._log_disconnect()
+        await self.event_bus.emit(ConnectionDisconnectEvent())
 
-            # remove websocket
-            self.ws = None
-            return None
+    def is_connected(self) -> bool:
+        return self.socket is not None and not self.socket.closed
+    
+    async def send_event(self, event: ClientEvent) -> None:
+        if not self.is_connected():
+            await self.event_bus.emit(ConnectionDisconnectEvent())
 
-        if message is bytes:
-            raise Exception("message is bytes, expected str")
+        try:
+            message = event.as_dict()
 
-        return str(message)
+            mode = event.on_send()
 
-    async def read_event(self) -> Optional[Event]:
-        message = await self.read_message()
+            if mode != ClientEventMode.DISPATCH:
+                self.logger.debug(
+                    f"Did not send event {event} with data {message} because of mode {mode.name}")
+                 
+                return
+            
+            await self.socket.send_json(message)
+            self.logger.debug(f"Sent event {event} with data {message}")
+            
+        except ConnectionResetError as e:
+            self.logger.error(f"Failed to send event {event}: {e}")
+            await self.event_bus.emit(ConnectionDisconnectEvent())
 
-        if message is None:
-            return None
+    async def poll_event(self, timeout=None) -> None:
+        if not self.is_connected():
+            await self.event_bus.emit(ConnectionDisconnectEvent())
+            return
 
         try:
-            packet: Dict[str, Any] = json.loads(message)
-        except json.JSONDecodeError:
-            self.logger.debug(f"Invalid message, not JSON: {message}")
-            return None
+            message = await self.socket.receive(timeout=timeout)
 
-        event: str = packet.get("type", "")
-        data: Dict[str, Any] = packet.get("data", {})
-
-        if event == "demand":
-            demand = data.get("demand", "UNDEFINED")
+            if message.type in (WSMsgType.CLOSED, WSMsgType.CLOSING, WSMsgType.CLOSE):
+                await self.event_bus.emit(ConnectionDisconnectEvent())
+                return
             
-            if demand == "pause": return PauseEvent()
-            elif demand == "resume": return ResumeEvent()
-            elif demand == "cancel": return CancelEvent()
-            elif demand == "terminal": return TerminalEvent(data)
-            elif demand == "gcode": return GcodeEvent(data)
-            elif demand == "test_webcam": return WebcamTestEvent()
-            elif demand == "webcam_snapshot": return WebcamSnapshotEvent(data)
-            elif demand == "file": return FileEvent(data)
-            elif demand == "start_print": return StartPrintEvent()
-            elif demand == "connect_printer": return ConnectPrinterEvent()
-            elif demand == "disconnect_printer": return DisconnectPrinterEvent()
-            elif demand == "system_restart": return SystemRestartEvent()
-            elif demand == "system_shutdown": return SystemShutdownEvent()
-            elif demand == "api_restart": return ApiRestartEvent()
-            elif demand == "api_shutdown": return ApiShutdownEvent()
-            elif demand == "update": return UpdateEvent()
-            elif demand == "plugin_install": return PluginInstallEvent()
-            elif demand == "plugin_uninstall": return PluginUninstallEvent()
-            elif demand == "webcam_settings_updated": return WebcamSettingsEvent(data)
-            elif demand == "stream_on": return StreamOnEvent(data)
-            elif demand == "stream_off": return StreamOffEvent()
-            elif demand == "set_printer_profile": return SetPrinterProfileEvent(data)
-            elif demand == "get_gcode_script_backups": return GetGcodeScriptBackupsEvent(data)
-            elif demand == "has_gcode_changes": return HasGcodeChangesEvent(data)
-            elif demand == "psu_off": return PsuControlEvent(False)
-            elif demand == "psu_on": return PsuControlEvent(True)
-            elif demand == "psu_keepalive": return PsuControlEvent(True)
-            elif demand == "disable_websocket": return DisableWebsocketEvent(data)
-            else:
-                # Return what ever
-                self.logger.debug(f"Unknown demand: {demand} data: {data}")
-                return None
-        elif event == "error": return ErrorEvent(data)
-        elif event == "new_token": return NewTokenEvent(data)
-        elif event == "connected": return ConnectEvent(data)
-        elif event == "pause": return PauseEvent()
-        elif event == "complete_setup": return SetupCompleteEvent(data)
-        elif event == "interval_change": return IntervalChangeEvent(data)
-        elif event == "pong": return PongEvent()
-        elif event == "stream_received": return StreamReceivedEvent()
-        elif event == "printer_settings": return PrinterSettingsEvent(data)
-        else:
-            self.logger.debug(f"Unknown event: {event} data: {data}")
-            return None
\ No newline at end of file
+            if message.type == WSMsgType.ERROR:
+                self.logger.error(f"Websocket error: {str(message.data)}")
+                await self.event_bus.emit(ConnectionDisconnectEvent())
+                return
+            
+            if message.type == WSMsgType.BINARY:
+                message.data = message.data.decode("utf-8")
+
+            try:
+                event: Dict[str, Any] = json.loads(message.data)
+            except json.JSONDecodeError:
+                self.logger.error(f"Failed to parse event: {message.data}")
+                return
+
+            name: str = event.get("type", "")
+            data: Dict[str, Any] = event.get("data", {})
+            for_client: Optional[int] = event.get("for")
+            demand: Optional[str] = data.get("demand")
+
+            try:
+                event: ServerEvent = get_event(name, demand, data)
+            except KeyError as e:
+                self.logger.error(f"Unknown event type {e.args[0]}")
+                return
+
+            self.logger.debug(
+                f"Recieved event {event} with data {message.data} for client {for_client}")
+            
+            await self.event_bus.emit(ConnectionEventReceivedEvent(event, for_client))
+
+        except (CancelledError, TimeoutError, ConnectionResetError):
+            await self.event_bus.emit(ConnectionDisconnectEvent())
\ No newline at end of file
diff --git a/simplyprint_ws_client/const.py b/simplyprint_ws_client/const.py
index 7807b56..70f6a03 100644
--- a/simplyprint_ws_client/const.py
+++ b/simplyprint_ws_client/const.py
@@ -1,7 +1,29 @@
-VERSION = "0.0.6"
-CONFIG_FILE_PATH: str = "config.json"
+import importlib.metadata
+from enum import Enum
+from os import environ
 
-WEBSOCKET_URL: str = "wss://ws.simplyprint.io"
-REACHABLE_URL: str = "https://ws.simplyprint.io"
-SNAPSHOT_ENDPOINT = "https://api.simplyprint.io/jobs/ReceiveSnapshot"
-AI_ENDPOINT = "https://ai.simplyprint.io/api/v2/infer"
+from platformdirs import AppDirs
+
+VERSION = importlib.metadata.version("simplyprint_ws_client") or "development"
+
+APP_DIRS = AppDirs("SimplyPrint", "SimplyPrint")
+
+class SimplyPrintWsVersion(Enum):
+    VERSION_0_1 = "0.1"
+    VERSION_0_2 = "0.2"
+
+SUPPORTED_SIMPLYPRINT_VERSION = "4.1.3"
+
+IS_TESTING = bool(environ.get("IS_TESTING")) or bool(environ.get("DEV_MODE")) or bool(environ.get("DEBUG"))
+BASE_URL = "{}://{}.simplyprint.io/{}"
+
+PROD_WEBSOCKET_URL = BASE_URL.format("wss", "ws", SimplyPrintWsVersion.VERSION_0_2.value)
+TEST_WEBSOCKET_URL = BASE_URL.format("wss", "testws3", SimplyPrintWsVersion.VERSION_0_2.value)
+STAG_WEBSOCKET_URL = BASE_URL.format("wss", "wsstaging", SimplyPrintWsVersion.VERSION_0_2.value)
+
+PROD_API_URL = BASE_URL.format("https", "api", "")
+TEST_API_URL = BASE_URL.format("https", "test", "api/")
+STAG_API_URL = BASE_URL.format("https", "staging", "api/")
+
+API_URL = PROD_API_URL if not IS_TESTING else TEST_API_URL
+WEBSOCKET_URL = PROD_WEBSOCKET_URL if not IS_TESTING else TEST_WEBSOCKET_URL
\ No newline at end of file
diff --git a/simplyprint_ws_client/event.py b/simplyprint_ws_client/event.py
deleted file mode 100644
index 51dd72b..0000000
--- a/simplyprint_ws_client/event.py
+++ /dev/null
@@ -1,203 +0,0 @@
-from .timer import Intervals
-from .printer_state import PrinterSettings
-
-from enum import Enum
-from typing import (
-    Optional,
-    Dict,
-    List,
-    Any,
-)
-
-class PrinterEvent(Enum):
-    PING = "ping"
-    LATENCY = "latency"
-    TOOL = "tool"
-    STATUS = "state_change"
-    AMBIENT = "ambient"
-    TEMPERATURES = "temps"
-    SHUTDOWN = "shutdown"
-    CONNECTION = "connection"
-    CAMERA_SETTINGS = "camera_settings"
-    GCODE_TERMINAL = "gcode_terminal"
-    JOB_UPDATE = "job_update"
-    JOB_INFO = "job_info"
-    PLUGIN_INSTALLED = "plugin_installed"
-    FILE_PROGRESS = "file_progress"
-    PSU_CHANGE = "psu_change"
-    CPU = "cpu"
-    STREAM = "stream"
-    MESH_DATA = "mesh_data"
-    MACHINE_DATA = "machine_data"
-    PRINT_STARTED = "print_started"
-    PRINT_DONE = "print_done"
-    PRINT_PAUSING = "print_pausing"
-    PRINT_PAUSED = "print_paused"
-    PRINT_CANCELLED = "print_cancelled"
-    PRINT_FALIURE = "print_failure"
-    PRINTER_ERROR = "printer_error"
-    INPUT_REQUIRED = "input_required"
-    UPDATE_STARTED = "update_started"
-    FIRMWARE = "firmware"
-    AI_RESPONSE = "ai_resp"
-    WEBCAM_STATUS = "webcam_status"
-    UNSAFE_FIRMWARE = "unsafe_firmware"
-    FILAMENT_ANALYSIS = "filament_analysis"
-    OCTOPRINT_PLUGINS = "octoprint_plugins"
-
-class Event:
-    def __init__(self):        
-        pass
-
-class ErrorEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.error: str = data.get("error", "")
-
-class NewTokenEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.short_id: str = data.get("short_id", "")
-        self.token: str = data.get("token", "")
-        self.no_exist: bool = data.get("no_exist", False)
-
-class ConnectEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.in_set_up: bool = data.get("in_set_up", 0) == 1
-        self.intervals: Intervals = Intervals(data.get("interval", {}))
-        self.printer_settings: PrinterSettings = PrinterSettings(data.get("printer_settings", {}))
-        self.short_id: Optional[str] = data.get("short_id")
-        self.reconnect_token: Optional[str] = data.get("reconnect_token")
-        self.name: Optional[str] = data.get("name")
-
-class SetupCompleteEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.printer_id: str = data.get("printer_id", "")
-
-class IntervalChangeEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.intervals: Intervals = Intervals(data)
-
-class PongEvent(Event):
-    def __init__(self):
-        pass
-
-class StreamReceivedEvent(Event):
-    def __init__(self):
-        pass
-
-class PrinterSettingsEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.printer_settings: PrinterSettings = PrinterSettings(data)
-
-class PauseEvent(Event):
-    def __init__(self):
-        pass
-
-class ResumeEvent(Event):
-    def __init__(self):
-        pass
-
-class CancelEvent(Event):
-    def __init__(self):
-        pass
-
-class TerminalEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.enabled: bool = data.get("enabled", False)
-
-class DisplayMessageEvent(Event):
-    def __init__(self, message: str, short_branding: bool = False):
-        self.message: str = message
-        self.short_branding: bool = short_branding
-
-class GcodeEvent(Event):
-    def __init__(self, data: Dict[str, Any] = {}):
-        self.list: List[str] = data.get("list", [])
-
-class WebcamTestEvent(Event):
-    def __init__(self):
-        pass
-
-class WebcamSnapshotEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.id: Optional[str] = data.get("id")
-        self.timer: Optional[int] = data.get("timer")
-
-class FileEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.url: Optional[str] = data.get("url")
-        self.path: Optional[str] = data.get("path")
-        self.auto_start: bool = bool(data.get("auto_start", 0))
-
-class StartPrintEvent(Event):
-    def __init__(self):
-        pass
-
-class ConnectPrinterEvent(Event):
-    def __init__(self):
-        pass
-
-class DisconnectPrinterEvent(Event):
-    def __init__(self):
-        pass
-
-class SystemRestartEvent(Event):
-    def __init__(self):
-        pass
-
-class SystemShutdownEvent(Event):
-    def __init__(self):
-        pass
-
-class ApiRestartEvent(Event):
-    def __init__(self):
-        pass
-
-class ApiShutdownEvent(Event):
-    def __init__(self):
-        pass
-
-class UpdateEvent(Event):
-    def __init__(self):
-        pass
-
-class PluginInstallEvent(Event):
-    def __init__(self):
-        pass
-
-class PluginUninstallEvent(Event):
-    def __init__(self):
-        pass
-
-class WebcamSettingsEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.webcam_settings = data.get("webcam_settings")
-
-# deprecated
-class StreamOnEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.interval: float = data.get("interval", 300.0) / 1000.0
-
-# deprecated
-class StreamOffEvent(Event):
-    def __init__(self):
-        pass
-
-class SetPrinterProfileEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.profile = data.get("printer_profile")
-
-class GetGcodeScriptBackupsEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.force = data.get("force", False)
-
-class HasGcodeChangesEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.scripts = data.get("scripts")
-
-class PsuControlEvent(Event):
-    def __init__(self, on: bool):
-        self.on: bool = on
-
-class DisableWebsocketEvent(Event):
-    def __init__(self, data: Dict[str, Any]):
-        self.websocket_ready: bool = data.get("websocket_ready", False)
diff --git a/simplyprint_ws_client/events/__init__.py b/simplyprint_ws_client/events/__init__.py
new file mode 100644
index 0000000..28a72db
--- /dev/null
+++ b/simplyprint_ws_client/events/__init__.py
@@ -0,0 +1,21 @@
+from .server_events import ServerEvent
+from .demands import DemandEvent
+
+from . import server_events as Events
+from . import demands as Demands
+
+from typing import Dict, Type, Union, Any, Optional
+
+# Construct hashmap of events (sub-hashmap for demands)
+_events: Dict[str, Union[Type[ServerEvent], Dict[str, Type[DemandEvent]]]] = { event.get_name(): event for event in ServerEvent.__subclasses__() if event.get_name() != DemandEvent }
+_events[DemandEvent]: Dict[str, Type[DemandEvent]] = { demand: event for event in DemandEvent.__subclasses__() for demand in (event.demand if isinstance(event.demand, list) else [event.demand]) }
+
+def get_event(name: str, demand: Optional[str] = None, data: Dict[str, any] = {}) -> ServerEvent:
+    """
+    Raises:
+        ValueError: If the event does not exist
+    """
+    if demand is None:
+        return _events[name](name, data)
+    else:
+        return _events[DemandEvent][demand](name, demand, data)
diff --git a/simplyprint_ws_client/events/client_events.py b/simplyprint_ws_client/events/client_events.py
new file mode 100644
index 0000000..053f368
--- /dev/null
+++ b/simplyprint_ws_client/events/client_events.py
@@ -0,0 +1,303 @@
+from abc import abstractmethod
+from enum import Enum
+from typing import Any, Dict, Generator, Optional, Tuple, TYPE_CHECKING
+
+from ..helpers.intervals import IntervalException, IntervalTypes
+from ..events.event import Event
+
+if TYPE_CHECKING:
+    from ..state.printer import PrinterState
+
+class PrinterEvent(Enum):
+    PING = "ping"
+    LATENCY = "latency"
+    TOOL = "tool"
+    STATUS = "state_change"
+    AMBIENT = "ambient"
+    TEMPERATURES = "temps"
+    SHUTDOWN = "shutdown"
+    CONNECTION = "connection"
+    CAMERA_SETTINGS = "camera_settings"
+    JOB_INFO = "job_info"
+    FILE_PROGRESS = "file_progress"
+    PSU_CHANGE = "psu_change"
+    CPU_INFO = "cpu_info"
+    PSU = "power_controller"
+    STREAM = "stream"
+    PRINTER_ERROR = "printer_error"
+    MESH_DATA = "mesh_data"
+    INFO = "machine_data"
+    INPUT_REQUIRED = "input_required"
+    UPDATE_STARTED = "update_started"
+    FIRMWARE = "firmware"
+    WEBCAM = "webcam"
+    WEBCAM_STATUS = "webcam_status"
+    UNSAFE_FIRMWARE = "unsafe_firmware"
+    FILAMENT_ANALYSIS = "filament_analysis"
+    OCTOPRINT_PLUGINS = "octoprint_plugins"
+    GCODE_SCRIPTS = "gcode_scripts"
+    INSTALLED_PLUGINS = "installed_plugins"
+    SOFTWARE_UPDATES = "software_updates"
+    FIRMWARE_WARNING = "firmware_warning"
+    AI_RESP = "ai_resp"
+    LOGS_SENT = "logs_sent"
+    FILAMENT_SENSOR = "filament_sensor"
+    MATERIAL_DATA = "material_data"
+
+class ClientEventMode(Enum):
+    DISPATCH = 0
+    RATELIMIT = 1
+    CANCEL = 2
+
+class ClientEvent(Event):
+    event_type: PrinterEvent
+    interval_type: Optional[IntervalTypes] = None
+    
+    state: 'PrinterState'
+    for_client: Optional[int]
+    data: Optional[Dict[str, Any]]
+
+    def __init__(self, state = None, for_client: Optional[int] = None, data: Optional[Dict[str, Any]] = None) -> None:
+        """
+        state (PrinterState): The state of the printer at the time of the event.
+        for_client (int): Id of client event belongs to
+        data (Optional[Dict[str, Any]], optional): Custom data to send with the event. Defaults to None.
+        """
+        self.state = state
+        self.for_client = for_client
+        self.data = data
+
+    @classmethod
+    def get_name(cls) -> Optional[str]:
+        if cls is ClientEvent:
+            return None
+
+        return cls.event_type.value
+
+    @abstractmethod
+    def generate_data(self) -> Optional[Generator[Tuple, None, None]]:
+        pass
+
+    def generate(self) -> Generator[Tuple, None, None]:
+        yield "type", self.get_name()
+
+        if not self.for_client is None and self.for_client != 0:
+            yield "for", self.for_client
+
+        if self.data is not None:
+            yield "data", self.data
+        elif (data_generator := self.generate_data()) is not None:
+            yield "data", dict(data_generator)
+
+    def as_dict(self) -> Dict[str, Any]:
+        return dict(self.generate())
+    
+    def on_send(self) -> ClientEventMode:
+        if self.interval_type is not None:
+            try:
+                self.state.intervals.use(self.interval_type.value)
+            except IntervalException:
+                return ClientEventMode.RATELIMIT    
+    
+        return ClientEventMode.DISPATCH
+
+class GcodeScriptsEvent(ClientEvent):
+    event_type = PrinterEvent.GCODE_SCRIPTS
+
+class MachineDataEvent(ClientEvent):
+    event_type = PrinterEvent.INFO
+    
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        for key, value in self.state.info.trait_values().items():
+            yield key, value
+    
+class WebcamStatusEvent(ClientEvent):
+    event_type = PrinterEvent.WEBCAM_STATUS
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        yield "connected", self.state.webcam_info.connected
+
+class WebcamEvent(ClientEvent):
+    event_type = PrinterEvent.WEBCAM
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        for key, value in self.state.webcam_settings.trait_values().items():
+            if self.state.has_changed(self.state.webcam_settings, key):
+                yield key, value
+
+class InstalledPluginsEvent(ClientEvent):
+    event_type = PrinterEvent.INSTALLED_PLUGINS
+
+class SoftwareUpdatesEvent(ClientEvent):
+    event_type = PrinterEvent.SOFTWARE_UPDATES
+
+class FirmwareEvent(ClientEvent):
+    event_type = PrinterEvent.FIRMWARE
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        for key, value in self.state.firmware.trait_values().items():
+            if self.state.has_changed(self.state.firmware, key):
+                yield f"firmware_{key}", value
+
+class FirmwareWarningEvent(ClientEvent):
+    event_type = PrinterEvent.FIRMWARE_WARNING
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        for key, value in self.state.firmware.trait_values().items():
+            yield key, value
+
+class ToolEvent(ClientEvent):
+    event_type = PrinterEvent.TOOL
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        yield "new", self.state.active_tool
+
+class TemperatureEvent(ClientEvent):
+    event_type = PrinterEvent.TEMPERATURES
+    interval_type = IntervalTypes.TEMPS
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        if self.state.has_changed(self.state.bed_temperature):
+            yield "bed", self.state.bed_temperature.to_list()
+
+        for i, tool in enumerate(self.state.tool_temperatures):
+            if self.state.has_changed(tool):
+                yield f"tool{i}", tool.to_list()
+
+    def on_send(self) -> ClientEventMode:
+        # If we have a target temperature, send it more often (use IntervalTypes.TEMPS_TARGET)
+        if self.state.bed_temperature.target is not None or any([tool.target is not None for tool in self.state.tool_temperatures]):
+            self.interval_type = IntervalTypes.TEMPS_TARGET
+        else:
+            self.interval_type = IntervalTypes.TEMPS
+
+        return super().on_send()
+
+class AmbientTemperatureEvent(ClientEvent):
+    event_type = PrinterEvent.AMBIENT
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        yield "new", round(self.state.ambient_temperature.ambient)
+
+class ConnectionEvent(ClientEvent):
+    event_type = PrinterEvent.CONNECTION
+
+class StateChangeEvent(ClientEvent):
+    event_type = PrinterEvent.STATUS
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        yield "new", self.state.status.value
+
+class JobInfoEvent(ClientEvent):
+    event_type = PrinterEvent.JOB_INFO
+    interval_type = IntervalTypes.JOB
+    force_dispatch: bool = False
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        for key, value in self.state.job_info.trait_values().items():
+            if self.state.has_changed(self.state.job_info, key):
+                if key in ["started", "finished", "cancelled", "failed"]:
+                    # Only send updates in terms of true, since they
+                    # are mutually exclusive.
+                    if not value: continue
+        
+                yield key, value if key != 'progress' else round(value)
+    
+    def on_send(self) -> ClientEventMode:
+        # Ensure we never drop job status updates
+        return ClientEventMode.DISPATCH
+
+# TODO in the future
+class AiResponseEvent(ClientEvent):
+    event_type = PrinterEvent.AI_RESP
+
+class PrinterErrorEvent(ClientEvent):
+    event_type = PrinterEvent.PRINTER_ERROR
+
+class ShutdownEvent(ClientEvent):
+    event_type = PrinterEvent.SHUTDOWN
+
+class StreamEvent(ClientEvent):
+    event_type = PrinterEvent.STREAM
+    interval_type = IntervalTypes.WEBCAM
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        yield "base", self.data.get("base")
+
+class PingEvent(ClientEvent):
+    event_type = PrinterEvent.PING
+    interval_type = IntervalTypes.PING
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        ...
+
+class LatencyEvent(ClientEvent):
+    event_type = PrinterEvent.LATENCY
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        yield "ms", self.ping_pong.pong - self.ping_pong.ping
+
+class FileProgressEvent(ClientEvent):
+    event_type = PrinterEvent.FILE_PROGRESS
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        """
+        When a file progress event is triggered, always yield state, the two other fields 
+        percent and message are optionally tied to respectfully downloading and error states.
+
+        But since we always send the state you can update any other fields you want to send and still 
+        achieve the same result.
+        """
+        yield "state", self.state.file_progress.state.value
+
+        for key, value in self.state.file_progress.trait_values().items():
+            if key == "state": continue
+            if self.state.has_changed(self.state.file_progress, key):
+                yield key, value
+
+    def on_send(self) -> ClientEventMode:
+        # Ensure we never drop file progress updates
+        return ClientEventMode.DISPATCH
+
+class FilamentSensorEvent(ClientEvent):
+    event_type = PrinterEvent.FILAMENT_SENSOR
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        yield "state", self.state.filament_sensor.state
+
+class PowerControllerEvent(ClientEvent):
+    event_type = PrinterEvent.PSU
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        yield "on", self.state.psu_info.on
+
+class CpuInfoEvent(ClientEvent):
+    event_type = PrinterEvent.CPU_INFO
+    interval_type = IntervalTypes.CPU
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        for key, value in vars(self.state.cpu_info).get("_trait_values", dict()).items():
+            if self.state.has_changed(self.state.cpu_info, key):
+                yield key, value
+
+class MeshDataEvent(ClientEvent):
+    event_type = PrinterEvent.MESH_DATA
+
+class LogsSentEvent(ClientEvent):
+    event_type = PrinterEvent.LOGS_SENT
+
+class MaterialDataEvent(ClientEvent):
+    event_type = PrinterEvent.MATERIAL_DATA
+    has_changes = False
+
+    def generate_data(self) -> Generator[Tuple, None, None]:
+        if self.state.has_changed(self.state, "material_data"):
+            if len(self.state.material_data) == 0: return
+            yield "materials", [material.trait_values() for material in self.state.material_data]
+            self.has_changes = True
+
+    def on_send(self) -> ClientEventMode:
+        if self.has_changes:
+            return ClientEventMode.DISPATCH
+        
+        return ClientEventMode.CANCEL
\ No newline at end of file
diff --git a/simplyprint_ws_client/events/demands.py b/simplyprint_ws_client/events/demands.py
new file mode 100644
index 0000000..b3c8bef
--- /dev/null
+++ b/simplyprint_ws_client/events/demands.py
@@ -0,0 +1,141 @@
+from typing import Any, Dict, List, Optional, Union
+from .server_events import ServerEvent
+
+class DemandEvent(ServerEvent):
+    event_type = "demand"
+    demand: Union[str, List[str]] = ""
+
+    def __init__(self, name: str, demand: str, data: Dict[str, Any] = {}):
+        super().__init__(name, data)
+
+        if demand not in self.demand:
+            raise ValueError(f"Demand type {name} does not match demand {self.demand}")
+
+        self.demand = demand
+
+    @classmethod
+    def get_name(cls) -> Optional[str]:
+        if cls is DemandEvent:
+            return None
+
+        return cls.demand or cls.event_type
+
+class PauseEvent(DemandEvent):
+    demand = "pause"
+
+class ResumeEvent(DemandEvent):
+    demand = "resume"
+
+class CancelEvent(DemandEvent):
+    demand = "cancel"
+
+class TerminalEvent(DemandEvent):
+    demand = "terminal"
+
+    def on_event(self):
+        self.enabled: bool = self.data.get("enabled", False)
+
+class GcodeEvent(DemandEvent):
+    demand = "gcode"
+
+    def on_event(self):
+        self.list: List[str] = self.data.get("list", [])
+
+class WebcamTestEvent(DemandEvent):
+    demand = "webcam_test"
+
+class WebcamSnapshotEvent(DemandEvent):
+    demand = "webcam_snapshot"
+    
+    def on_event(self):
+        self.id: Optional[str] = self.data.get("id")
+        self.timer: Optional[int] = self.data.get("timer")
+
+class FileEvent(DemandEvent):
+    demand = "file"
+
+    def on_event(self):
+        self.auto_start: bool = bool(self.data.get("auto_start", 0))
+        self.url: Optional[str] = self.data.get("url")
+        self.cdn_url: Optional[str] = self.data.get("cdn_url")
+        self.file_name: Optional[str] = self.data.get("file_name")
+        self.file_id: str = self.data.get("file_id")
+        self.file_size: Optional[int] = self.data.get("file_size")
+        self.start_options: Dict[str, bool] = self.data.get("start_options", None)
+        self.mms_map: Dict[str, Any] = self.data.get("mms_map", None)
+
+class StartPrintEvent(DemandEvent):
+    demand = "start_print"
+
+class ConnectPrinterEvent(DemandEvent):
+    demand = "connect_printer"
+
+class DisconnectPrinterEvent(DemandEvent):
+    demand = "disconnect_printer"
+
+class SystemRestartEvent(DemandEvent):
+    demand = "system_restart"
+
+class SystemShutdownEvent(DemandEvent):
+    demand = "system_shutdown"
+
+class ApiRestartEvent(DemandEvent):
+    demand = "api_restart"
+
+class ApiShutdownEvent(DemandEvent):
+    demand = "api_shutdown"
+
+class UpdateEvent(DemandEvent):
+    demand = "update"
+
+class PluginInstallEvent(DemandEvent):
+    demand = "plugin_install"
+
+class PluginUninstallEvent(DemandEvent):
+    demand = "plugin_uninstall"
+
+class WebcamSettingsEvent(DemandEvent):
+    demand = "webcam_settings_updated"
+    def on_event(self):
+        self.webcam_settings = self.data.get("webcam_settings")
+
+# deprecated
+class StreamOnEvent(DemandEvent):
+    demand = "stream_on"
+
+    def on_event(self):
+        self.interval: float = self.data.get("interval", 300.0) / 1000.0
+
+# deprecated
+class StreamOffEvent(DemandEvent):
+    demand = "stream_off"
+
+class SetPrinterProfileEvent(DemandEvent):
+    demand = "set_printer_profile"
+
+    def on_event(self):
+        self.profile = self.data.get("printer_profile")
+
+class GetGcodeScriptBackupsEvent(DemandEvent):
+    demand = "get_gcode_script_backups"
+
+    def on_event(self):
+        self.force = self.data.get("force", False)
+
+class HasGcodeChangesEvent(DemandEvent):
+    demand = "has_gcode_changes"
+
+    def on_event(self):
+        self.scripts = self.data.get("scripts")
+
+class PsuControlEvent(DemandEvent):
+    demand = ["psu_on", "psu_off", "psu_keepalive"]
+
+    def on_event(self):
+        self.on: bool = self.demand != "psu_off"
+
+class DisableWebsocketEvent(DemandEvent):
+    demand = "disable_websocket"
+
+    def on_event(self):
+        self.websocket_ready: bool = self.data.get("websocket_ready", False)
\ No newline at end of file
diff --git a/simplyprint_ws_client/events/event.py b/simplyprint_ws_client/events/event.py
new file mode 100644
index 0000000..e4f8da2
--- /dev/null
+++ b/simplyprint_ws_client/events/event.py
@@ -0,0 +1,36 @@
+class EventTraits:
+    def __str__(cls):
+        return cls.get_name()
+    
+    def __repr__(cls) -> str:
+        return f"<{cls.__base__.__name__} {cls.get_name()}>"
+    
+    def __eq__(cls, other: object) -> bool:
+        if isinstance(other, str): return cls.get_name() == other
+        if isinstance(other, Event): return cls.get_name() == other.get_name()
+        if isinstance(other, EventTraits): return cls.get_name() == other.get_name()
+        return False
+    
+    def __hash__(cls) -> int:
+        if cls.get_name() is None: return hash(cls.__class__)
+
+        return hash(cls.get_name())
+
+class EventType(EventTraits, type):
+    ...
+
+class Event(EventTraits, metaclass=EventType):
+    """
+    Base event class for type-hinting, not required to be used.
+    """
+    
+    @classmethod
+    def get_name(cls) -> str:
+        return cls.__name__
+    
+    # Allow for propegation control of events.
+    def is_stopped(self) -> bool:
+        return hasattr(self, "_stopped") and self._stopped
+    
+    def stop_event(self) -> None:
+        self._stopped = True
\ No newline at end of file
diff --git a/simplyprint_ws_client/events/event_bus.py b/simplyprint_ws_client/events/event_bus.py
new file mode 100644
index 0000000..8b3ed39
--- /dev/null
+++ b/simplyprint_ws_client/events/event_bus.py
@@ -0,0 +1,112 @@
+import asyncio
+import heapq
+from typing import (Callable, Dict, Generator, Generic, Hashable, List,
+                    Optional, Tuple, Type, TypeVar, get_args)
+
+from .event import Event
+
+TEvent = TypeVar('TEvent', bound=object)
+
+class EventBus(Generic[TEvent]):
+    _generic_class: Type[TEvent]
+
+    handlers: Dict[Hashable, List[Tuple[int, Callable]]]
+    _generic_handlers: Dict[str, List[Tuple[int, Callable]]]
+
+    def __init__(self):
+        self.handlers = {}
+        self._generic_handlers = {}
+
+        try:
+            self._generic_class = get_args(self.__class__.__orig_bases__[0])[0]
+        except:
+            self._generic_class = Event
+
+        if not isinstance(self._generic_class, type):
+            self._generic_class = Event
+
+    def on(self, event: Hashable, handler: Optional[Callable] = None, priority: int = 0):
+        return self._register_handler(self.handlers, event, handler, priority)
+    
+    def on_generic(self, event: Hashable, handler: Optional[Callable] = None, priority: int = 0):
+        return self._register_handler(self._generic_handlers, event, handler, priority)
+
+    def _register_handler(self, handlers: List, event: Hashable, handler: Optional[Callable] = None, priority: int = 0):
+        if event not in handlers:
+            handlers[event] = []
+
+        def wrapper(func):
+            try:
+                heapq.heappush(handlers[event], (priority, id(func), func))
+            except TypeError:
+                pass
+
+            return func
+        
+        if handler is None:
+            return wrapper
+        else:
+            return wrapper(handler)        
+
+    def off(self, event: Hashable, handler: Callable):
+        for handler in self._get_event_handlers(event):
+            if handler != handler:
+                continue
+
+            for i, (_, _, handler) in reversed(list(enumerate(self.handlers[event]))):
+                if handler == handler:
+                    del self.handlers[event][i]
+                    break
+
+            for i, (_, _, handler) in reversed(list(enumerate(self._generic_handlers[event]))):
+                if handler == handler:
+                    del self._generic_handlers[event][i]
+                    break
+
+    def _merge_args(self, handled, args):
+        if isinstance(handled, tuple):
+            args = handled + args
+        else:
+            args = args + (handled,)
+
+        return args
+    
+    def _is_allowed_event(self, event: Hashable):
+        return isinstance(event, self._generic_class) or event in self.handlers
+    
+    def _get_event_handlers(self, event: Hashable) -> Generator[Callable, None, None]:
+        for _, _, handler in self.handlers.get(event, []):
+            yield handler
+
+        # First handle generic handlers
+        for cls, handlers in self._generic_handlers.items():
+            if isinstance(event, cls):
+                for _, _, handler in handlers:
+                    yield handler
+        
+    async def emit(self, event: Hashable, *args, **kwargs):
+        is_event_obj = isinstance(event, self._generic_class)
+
+        if is_event_obj and not event in args:
+            args = self._merge_args(event, args)
+        
+        for handler in self._get_event_handlers(event):                 
+            handled = None
+
+            if asyncio.iscoroutinefunction(handler):
+                handled = await handler(*args, **kwargs)
+            else:
+                handled = handler(*args, **kwargs)
+
+            if is_event_obj and event.is_stopped():
+                break
+
+            if handled is not None:
+                args = self._merge_args(handled, args)
+        
+    def clear(self, event: Optional[Hashable] = None):
+        if event is None:
+            self.handlers.clear()
+        elif event in self.handlers:
+            del self.handlers[event]
+
diff --git a/simplyprint_ws_client/events/server_events.py b/simplyprint_ws_client/events/server_events.py
new file mode 100644
index 0000000..a71f640
--- /dev/null
+++ b/simplyprint_ws_client/events/server_events.py
@@ -0,0 +1,124 @@
+from abc import abstractmethod
+from typing import Dict, Type, Any, Optional
+
+from .event import Event
+from ..helpers.intervals import Intervals
+from ..state.printer import PrinterSettings, PrinterDisplaySettings
+
+
+class ServerEventError(ValueError):
+    pass
+
+class ServerEvent(Event):
+    event_type: str
+    data: Dict[str, Any] = {}
+    
+    # Generic event data
+    def __init__(self, event_type: str, data: Dict[str, Any] = {}):
+        self.data = data
+
+        if self.event_type != event_type:
+            raise ServerEventError(f"Event type {type} does not match event name {self.event_type}")
+        
+        self.on_event()
+
+    # Better for debugging
+    def __str__(self):
+        return f"<{self.__class__.__base__.__name__} {self.get_name()} {self.data}>"
+
+    def __repr__(self):
+        return f"<{self.__class__.__base__.__name__} {self.get_name()} {self.data}>"
+
+    @classmethod
+    def get_name(cls) -> Optional[str]:
+        if cls is ServerEvent:
+            return None
+
+        return cls.event_type
+
+    @abstractmethod
+    def on_event(self):
+        """
+        Secondary constructor for events, useful for when the event is received to avoid super calls
+        """
+        pass
+
+    @classmethod
+    def on(cls: Type['ServerEvent'], func):
+        """
+        Decorator for registering a handle for a specific event.
+        We do this by marking the function with the event type, so
+        we later can extract the event type from the function.
+        """
+        func._event = cls
+        func._pre = 0
+        return func
+    
+    @classmethod
+    def before(cls: Type['ServerEvent'], func):
+        """
+        Decorator for registering a pre-handle for a specific event.
+        This calls the actual handler, and has to return the event.
+        """
+        func._event = cls
+        func._pre = 1
+        return func
+
+class ErrorEvent(ServerEvent):
+    event_type = "error"
+
+    def on_event(self):
+        self.error: str = self.data.get("error", "")
+
+class NewTokenEvent(ServerEvent):
+    event_type = "new_token"
+
+    def on_event(self):
+        self.short_id: str = self.data.get("short_id", "")
+        self.token: str = self.data.get("token", "")
+        self.no_exist: bool = self.data.get("no_exist", False)
+
+class ConnectEvent(ServerEvent):
+    event_type = "connected"
+
+    def on_event(self):
+        self.in_setup: bool = bool(self.data.get("in_setup", 0))
+        self.intervals: Intervals = Intervals(self.data.get("interval", {}))
+        self.printer_settings: PrinterSettingsEvent = PrinterSettingsEvent(PrinterSettingsEvent.get_name(), self.data.get("printer_settings", {}))
+        self.short_id: Optional[str] = self.data.get("short_id")
+        self.reconnect_token: Optional[str] = self.data.get("reconnect_token")
+        self.printer_name: Optional[str] = self.data.get("name")
+
+class SetupCompleteEvent(ServerEvent):
+    event_type = "complete_setup"
+
+    def on_event(self):
+        self.printer_id: str = self.data.get("printer_id", "")
+
+class IntervalChangeEvent(ServerEvent):
+    event_type = "interval_change"
+
+    def on_event(self):
+        self.intervals: Intervals = Intervals(self.data)
+
+class PongEvent(ServerEvent):
+    event_type = "pong"
+
+class StreamReceivedEvent(ServerEvent):
+    event_type = "stream_received"
+
+class PrinterSettingsEvent(ServerEvent):
+    event_type = "printer_settings"
+
+    def on_event(self):
+        self.event_type = self.data.get("name", "")
+        self.printer_settings = PrinterSettings(has_psu=self.data.get("has_psu", False), has_filament_sensor=self.data.get("has_filament_sensor", False))
+        self.display_settings = PrinterDisplaySettings(**self.data.get("display", {}))
+
+class MultiPrinterAddResponseEvent(ServerEvent):
+    event_type = "add_connection"
+
+    def on_event(self): 
+        self.printer_id: Optional[int] = self.data.get("pid")
+        self.status: bool = self.data.get("status", False)
+        self.unique_id: Optional[str] = self.data.get("unique_id", "")
diff --git a/simplyprint_ws_client/examples/ping/ping_client.py b/simplyprint_ws_client/examples/ping/ping_client.py
new file mode 100644
index 0000000..e69de29
diff --git a/simplyprint_ws_client/examples/ping/ping_config.py b/simplyprint_ws_client/examples/ping/ping_config.py
new file mode 100644
index 0000000..e69de29
diff --git a/simplyprint_ws_client/examples/virtual/virtual_client.py b/simplyprint_ws_client/examples/virtual/virtual_client.py
new file mode 100644
index 0000000..20ef429
--- /dev/null
+++ b/simplyprint_ws_client/examples/virtual/virtual_client.py
@@ -0,0 +1,107 @@
+import asyncio
+import math
+
+from ...helpers.file_download import FileDownload
+from ...state.printer import PrinterStatus
+from ...client import DefaultClient
+from ...events import Events, Demands
+
+from .virtual_config import VirtualConfig
+
+
+def expt_smooth(target, actual, alpha, dt) -> float:
+    return actual + (target - actual) * (1.0 - math.exp(-alpha * dt))
+
+class VirtualClient(DefaultClient[VirtualConfig]):
+    def __init__(self, config: VirtualConfig):
+        super().__init__(config)
+
+        self.set_info("Virtual Printer", "0.0.1")
+        self.setup_sentry("https://a5aef1defa83433586dd0cf1c1fffe57@o1102514.ingest.sentry.io/6619552")
+
+        self.printer.bed_temperature.actual = 20.0
+        self.printer.tool_temperatures[0].actual = 20.0
+        self.printer.status = PrinterStatus.OPERATIONAL
+
+
+    @Events.ConnectEvent.on
+    async def on_connect(self, event: Events.ConnectEvent):
+        print("Yay i am connected :) :) :)")
+
+    @Demands.GcodeEvent.on
+    async def on_gcode(self, event: Demands.GcodeEvent):
+        print("Gcode: %s", event.list)
+
+        for gcode in event.list:
+            if gcode[:4] == "M104":
+                target = float(gcode[6:])
+
+                print(f"Setting tool temperature to {target}")
+
+                if target > 0.0:
+                    self.printer.tool_temperatures[0].target = target
+                else:
+                    self.printer.tool_temperatures[0].target = None
+
+            if gcode[:4] == "M140":
+                target = float(gcode[6:])
+
+                print(f"Setting bed temperature to {target}")
+
+                if target > 0.0:
+                    self.printer.bed_temperature.target = target
+                else:
+                    self.printer.bed_temperature.target = None
+
+    @Demands.FileEvent.on
+    async def on_file(self, event: Demands.FileEvent):
+        print(event)
+        downloader = FileDownload(self.printer.file_progress, asyncio.get_event_loop())
+        data = await downloader.download_as_bytes(event.url)
+
+    @Demands.StartPrintEvent.on
+    async def on_start_print(self, event: Demands.StartPrintEvent):
+        self.printer.status = PrinterStatus.PRINTING
+        self.printer.job_info.started = True
+        self.printer.job_info.progress = 0.0
+
+
+    async def tick(self):
+        # Update temperatures, printer status and so on with smoothing function
+        if self.printer.bed_temperature.target is None:
+            target = 20.0
+        else:
+            target = self.printer.bed_temperature.target
+
+        self.printer.bed_temperature.actual = expt_smooth(
+            target,
+            self.printer.bed_temperature.actual,
+            0.05,
+            0.1,
+        )
+
+        if self.printer.tool_temperatures[0].target is None:
+            target = 20.0
+
+        else:
+            target = self.printer.tool_temperatures[0].target
+
+        self.printer.tool_temperatures[0].actual = expt_smooth(
+            target,
+            self.printer.tool_temperatures[0].actual,
+            0.05,
+            0.1,
+        )
+
+        if self.printer.status == PrinterStatus.PRINTING:
+            self.printer.job_info.progress += expt_smooth(
+                100.0,
+                0.01,
+                0.01,
+                0.1,
+            )
+
+            if self.printer.job_info.progress > 100.0:
+                self.printer.status = PrinterStatus.OPERATIONAL
+                self.printer.job_info.finished = True
+                self.printer.job_info.progress = 100
\ No newline at end of file
diff --git a/simplyprint_ws_client/examples/virtual/virtual_config.py b/simplyprint_ws_client/examples/virtual/virtual_config.py
new file mode 100644
index 0000000..840d70e
--- /dev/null
+++ b/simplyprint_ws_client/examples/virtual/virtual_config.py
@@ -0,0 +1,4 @@
+from ...config import Config
+
+class VirtualConfig(Config):
+    ...
\ No newline at end of file
diff --git a/simplyprint_ws_client/file.py b/simplyprint_ws_client/file.py
deleted file mode 100644
index e9067d9..0000000
--- a/simplyprint_ws_client/file.py
+++ /dev/null
@@ -1,137 +0,0 @@
-import requests
-import time
-import pathlib
-import tempfile
-import logging
-import re
-import shutil
-
-from tornado.escape import url_escape, url_unescape
-from typing import Optional, Tuple, List
-from .event import PrinterEvent
-
-def escape_query_string(qs: str) -> str:
-    parts = qs.split("&")
-    escaped: List[str] = []
-    for p in parts:
-        item = p.split("=", 1)
-        key = url_escape(item[0])
-        if len(item) == 2:
-            escaped.append(f"{key}={url_escape(item[1])}")
-        else:
-            escaped.append(key)
-    return "&".join(escaped)
-
-class FileHandler:
-    def __init__(self, client, local_path: str):
-        self.client = client
-        self.logger = logging.getLogger("simplyprint.FileHandler")
-        self.local_path: str = local_path
-
-    def escape_url(self, url: str) -> str:
-        # escape the url
-        match = re.match(r"(https?://[^/?#]+)([^?#]+)?(\?[^#]+)?(#.+)?", url)
-        if match is not None:
-            uri, path, qs, fragment = match.groups()
-            if path is not None:
-                uri += "/".join([url_escape(p, plus=False)
-                                 for p in path.split("/")])
-            if qs is not None:
-                uri += "?" + escape_query_string(qs[1:])
-            if fragment is not None:
-                uri += "#" + url_escape(fragment[1:], plus=False)
-            url = uri
-        return url
-
-    def parse_content_disposition(self, content_disposition: str) -> str:
-        fnr = r"filename[^;\n=]*=(['\"])?(utf-8\'\')?([^\n;]*)(?(1)\1|)"
-        matches: List[Tuple[str, str, str]] = re.findall(fnr, content_disposition)
-        is_utf8 = False
-        filename: str = ""
-        for (_, encoding, fname) in matches:
-            if encoding.startswith("utf-8"):
-                # Prefer the utf8 filename if included
-                filename = url_unescape(
-                    fname, encoding="utf-8", plus=False)
-                is_utf8 = True
-                break
-            filename = fname
-        self.logger.debug(
-            "Content-Disposition header received: filename = "
-            f"{filename}, utf8: {is_utf8}"
-        )
-        return filename
-
-    def update_progress(self, progress: int):
-        self.client.send(
-            PrinterEvent.FILE_PROGRESS,
-            {
-                "state": "downloading",
-                "percent": progress,
-            }
-        )
-
-    def download_complete(self):
-        self.client.send(
-            PrinterEvent.FILE_PROGRESS,
-            {
-                "state": "ready",
-            }
-        )
-
-    def download_error(self, error: str):
-        self.client.send(
-            PrinterEvent.FILE_PROGRESS,
-            {
-                "state": "error",
-                "message": error,
-            }
-        )
-
-    def download(self, url: str) -> Optional[str]:
-        tmp_file = f"sp-{time.monotonic_ns()}.gcode"
-        tmp_file_path = pathlib.Path(tempfile.gettempdir()).joinpath(tmp_file)
-        size: int = 0
-        downloaded: int = 0
-        last_pct: float = 0.0
-        url_path = url.rsplit("/", 1)
-        file_name: str = tmp_file 
-
-        if len(url_path) == 2 and "." in url_path[-1]:
-            file_name = url_path[-1]
-
-        url = self.escape_url(url)
-
-        try:
-            with requests.get(url, allow_redirects=True, stream=True, timeout=3600.0) as response:
-                response.raise_for_status()
-                size = int(response.headers.get("content-length", 0))
-
-                update_pct = max(1.0, size // 100)
-
-                if "content-disposition" in response.headers:
-                    cd = response.headers["content-disposition"]
-                    file_name = self.parse_content_disposition(cd)
-
-                with tmp_file_path.open("wb") as f:
-                    for chunk in response.iter_content(8192):
-                        if chunk:
-                            f.write(chunk)
-                            downloaded += len(chunk)
-                            pct = downloaded / size * 100
-
-                            if pct > last_pct + update_pct or last_pct == 0.0 or pct >= 100.0:
-                                last_pct = pct
-                                self.update_progress(round(pct))
-                
-        except Exception:
-            self.logger.exception("Error downloading file")
-            self.download_error("Network Error")
-            return 
-
-        if not pathlib.Path(self.local_path).exists():
-            pathlib.Path(self.local_path).mkdir(parents=True)
-
-        shutil.move(tmp_file_path, f"{self.local_path}/{file_name}")
-        self.download_complete()
-        return file_name
diff --git a/simplyprint_ws_client/helpers/ambient_check.py b/simplyprint_ws_client/helpers/ambient_check.py
new file mode 100644
index 0000000..b351e30
--- /dev/null
+++ b/simplyprint_ws_client/helpers/ambient_check.py
@@ -0,0 +1,50 @@
+from traitlets import Float, Int
+
+from ..state.root_state import ClientState, to_event
+from ..events.client_events import AmbientTemperatureEvent
+
+from .temperature import Temperature
+from typing import Callable, List, Optional, Tuple
+
+class AmbientCheck:
+    AMBIENT_CHECK_TIME = 5.0 * 60.0
+    SAMPLE_CHACK_TIME = 20.0
+    CHECK_INTERVAL = 5.0
+
+    @staticmethod
+    def detect(on_changed: Callable[[int], None], tools: List[Temperature], initial_sample: Optional[float] = None, ambient: float = 0) -> Tuple[Optional[float], int, float]:
+        if len(tools) == 0:
+            return None, round(ambient), AmbientCheck.CHECK_INTERVAL
+
+        tool0 = tools[0]
+
+        if tool0.target is not None:
+            return None, round(ambient), AmbientCheck.AMBIENT_CHECK_TIME
+
+        if initial_sample is None:
+            return tool0.actual, round(ambient), AmbientCheck.SAMPLE_CHACK_TIME
+        else:
+            diff = abs(tool0.actual - initial_sample)
+            if diff <= 2.0:
+                ambient = (tool0.actual + initial_sample) / 2
+                if ambient != ambient:
+                    on_changed(round(ambient))
+                return None, round(ambient), AmbientCheck.AMBIENT_CHECK_TIME
+            else:
+                return tool0.actual, round(ambient), AmbientCheck.SAMPLE_CHACK_TIME
+
+@to_event(AmbientTemperatureEvent, "ambient")            
+class AmbientTemperatureState(ClientState):
+    initial_sample: Optional[float] = Float(allow_none=True)
+    ambient: int = Int()
+    update_interval: Optional[float] = Float(allow_none=True)
+
+    def on_changed_callback(self, new_ambient):
+        self.ambient = round(new_ambient)
+
+    def invoke_check(self, tool_temperatures: List[Temperature]):
+        """
+        It is up to the implementation to decide when to invoke the check or respect the update_interval, the entire state is self contained
+        and requires the tool_temperatures to be passed in from the PrinterState, but it handles triggering the appropriate events.
+        """
+        (self.initial_sample, self.ambient, self.update_interval) = AmbientCheck.detect(self.on_changed_callback, tool_temperatures, self.initial_sample, self.ambient)
diff --git a/simplyprint_ws_client/helpers/file_download.py b/simplyprint_ws_client/helpers/file_download.py
new file mode 100644
index 0000000..c101d87
--- /dev/null
+++ b/simplyprint_ws_client/helpers/file_download.py
@@ -0,0 +1,41 @@
+import asyncio
+import aiohttp
+
+from ..state.printer import PrinterFileProgressState, FileProgressState
+
+class FileDownload:
+    loop: asyncio.AbstractEventLoop
+    state: "PrinterFileProgressState"
+
+    def __init__(self, state: "PrinterFileProgressState", loop: asyncio.AbstractEventLoop) -> None:
+        self.loop = loop
+        self.state = state
+
+    async def download_as_bytes(self, url) -> bytes:
+        # Chunk the download so we can get progress
+        async with aiohttp.ClientSession(loop=self.loop) as session:
+            async with session.get(url) as resp:
+                if resp.status != 200:
+                    self.state.state = FileProgressState.ERROR
+                    self.state.message = f"Failed to download file: {resp.status}"
+                    return
+                
+                self.state.state = FileProgressState.STARTED
+
+                size = int(resp.headers.get('content-length', 0))
+                downloaded = 0
+
+                # Bytes object to store the downloaded data
+                data = b''
+
+                # Download chunk by chunk
+                async for chunk in resp.content.iter_any():
+                    data += chunk
+                    downloaded += len(chunk)
+
+                    self.state.state = FileProgressState.DOWNLOADING
+                    self.state.percent = round((downloaded / size) * 100, 2)
+        
+        # Return the data as a BytesIO object
+        self.state.state = FileProgressState.READY
+        return data
diff --git a/simplyprint_ws_client/helpers/file_manager.py b/simplyprint_ws_client/helpers/file_manager.py
new file mode 100644
index 0000000..4c4c87c
--- /dev/null
+++ b/simplyprint_ws_client/helpers/file_manager.py
@@ -0,0 +1,66 @@
+import base64
+import pathlib
+import string
+import time
+from typing import List, NamedTuple
+
+File = NamedTuple("File", [("name", str), ("size", int), ("last_modified", int)])
+
+
+class FileManager:
+    max_age: int
+    max_count: int
+    max_size: int
+    least_remaing_space_percentage: float
+
+    def __init__(self, max_age: int = 0, max_count=0, max_size=0, least_remaining_space_percentage=0.1) -> None:
+        self.max_age = max_age
+        self.max_count = max_count
+        self.max_size = max_size
+        self.least_remaing_space_percentage = least_remaining_space_percentage
+
+    @staticmethod
+    def get_smaller_file_id(file_id: str):
+        # Remove all non hex characters
+        padded_str = "".join([char for char in file_id if char in string.hexdigits])
+        padded_str = padded_str.ljust(len(padded_str) + (len(padded_str) % 2), "0")
+        return base64.urlsafe_b64encode(bytes.fromhex(padded_str)).decode().replace("=", "")
+
+    def get_file_path_by_id(self, file_id: str, extension: str = ".gcode") -> pathlib.Path:
+        return pathlib.Path(self.base_path).joinpath(file_id + extension)
+
+    def get_files_to_remove(self, files: List[File], total_disk_space: int, total_disk_usage: int):
+        # Sort files by last modified
+        files.sort(key=lambda file: file.last_modified)
+        time_now = time.time()
+        
+        for file in reversed(files):
+            if self.max_age > 0 and time_now - file.last_modified > self.max_age:
+                files.remove(file)
+                total_disk_usage -= file.size
+                yield file
+
+        for file in reversed(files):
+            if self.max_size > 0 and file.size > self.max_size:
+                files.remove(file)
+                total_disk_usage -= file.size
+                yield file
+
+        if self.least_remaing_space_percentage > 0:
+            space_left = total_disk_space - total_disk_usage
+            space_required = total_disk_space * self.least_remaing_space_percentage
+            
+            while len(files) > 0 and space_required > space_left:
+                file = files.pop(0)
+                total_disk_usage -= file.size
+                space_left = total_disk_space - total_disk_usage
+                yield file
+
+        if self.max_count > 0:
+            while len(files) > self.max_count:
+                file = files.pop(0)
+                total_disk_usage -= file.size
+                yield file
+
+    def does_file_id_exist(self, file_id, files: List[File]):
+        return any(file.name.split(".")[0] == file_id for file in files)
diff --git a/simplyprint_ws_client/helpers/intervals.py b/simplyprint_ws_client/helpers/intervals.py
new file mode 100644
index 0000000..0510f73
--- /dev/null
+++ b/simplyprint_ws_client/helpers/intervals.py
@@ -0,0 +1,77 @@
+from enum import Enum
+from collections import namedtuple
+
+from datetime import datetime, timedelta
+from typing import Dict, Union, List
+try:
+    from typing import Self
+except ImportError:
+    from typing_extensions import Self
+
+IntervalType = namedtuple('IntervalType', ['name', 'default_timing'])
+
+class IntervalException(ValueError):
+    pass
+
+class IntervalTypes(Enum):
+    AI = IntervalType("ai", 30000)
+    JOB = IntervalType("job", 5000)
+    TEMPS = IntervalType("temps", 5000)
+    TEMPS_TARGET = IntervalType("temps_target", 2500)
+    CPU = IntervalType("cpu", 30000)
+    RECONNECT = IntervalType("reconnect", 1000)
+    READY_MESSAGE = IntervalType("ready_message", 60000)
+    PING = IntervalType("ping", 20000)
+    WEBCAM = IntervalType("webcam", 1000)
+
+    @classmethod
+    def values(cls) -> List[IntervalType]:
+        return [t.value for t in cls]
+
+class Intervals:
+    intervals: Dict[IntervalType, float]
+    last_updates: Dict[IntervalType, float]
+
+    def __init__(self, data: Dict[Union[str, IntervalType], float] = {}):
+        self.intervals = {}
+        self.last_updates = {}
+        
+        for t, interval in data.items():
+            if isinstance(t, str): t = self._get_type_from_str(t)
+            self.intervals[t.name] = (interval or t.default_timing) / 1000.0
+            self.last_updates[t.name] = datetime.now() - timedelta(seconds=self.intervals[t.name])
+
+        for t in IntervalTypes.values():
+            if t not in self.intervals:
+                self.intervals[t.name] = t.default_timing / 1000.0
+                self.last_updates[t.name] = datetime.now() - timedelta(seconds=self.intervals[t.name])
+
+    def update(self, other: Self):
+        for t, interval in other.intervals.items():
+            self.intervals[t] = interval
+
+    def set(self, t: IntervalType, interval: float):
+        self.intervals[t.name] = (interval or t.default_timing) / 1000.0
+        
+        if not t.name in self.last_updates:
+            self.last_updates[t.name] = datetime.now() - timedelta(seconds=self.intervals[t.name])
+            
+    def update_raw(self, data: Dict[Union[str, IntervalType], float]):
+        for t, interval in data.items():
+            if isinstance(t, str): t = self._get_type_from_str(t)
+            self.intervals[t.name] = interval
+
+    def is_ready(self, t: IntervalType) -> bool:
+        if t.name not in self.intervals:
+            return True 
+        
+        return datetime.now() - self.last_updates[t.name] > timedelta(seconds=self.intervals[t.name])
+    
+    def use(self, t: IntervalType):
+        if not self.is_ready(t):
+            raise IntervalException(f"Interval {t.name} is not ready until {self.last_updates[t.name] + timedelta(seconds=self.intervals[t.name])} (now: {datetime.now()})")
+
+        self.last_updates[t.name] = datetime.now()
+
+    def _get_type_from_str(self, t: str) -> IntervalType:
+        return IntervalTypes[t.upper()].value
\ No newline at end of file
diff --git a/simplyprint_ws_client/client/info.py b/simplyprint_ws_client/helpers/physical_machine.py
similarity index 63%
rename from simplyprint_ws_client/client/info.py
rename to simplyprint_ws_client/helpers/physical_machine.py
index f17d50c..b607c43 100644
--- a/simplyprint_ws_client/client/info.py
+++ b/simplyprint_ws_client/helpers/physical_machine.py
@@ -8,20 +8,52 @@
 
 from typing import Optional
 
-class ClientInfo:
-    ui: Optional[str] = None
-    ui_version: Optional[str] = None
-    api: Optional[str] = None
-    api_version: Optional[str] = None
-    client: Optional[str] = None
-    client_version: Optional[str] = None
-    sp_version: Optional[str] = "4.0.0"
-    sentry_dsn: Optional[str] = None
-    development: bool = False
+class PhysicalMachine:
+    """
+    Provides hardware and platform information, abstractly.
+    """
+
+    def get_cpu(self):
+        temperature: Optional[float] = None
+        temperatures = psutil.sensors_temperatures()
+        
+        # Find the first temperature sensor that is not None
+        # Priority is given in reverse order
+        temperature_keys = {
+            "coretemp", 
+            "cpu-thermal",
+            "cpu_thermal", 
+            "soc_thermal"
+        } & set(temperatures.keys())
+        
+        temperature = temperatures[temperature_keys.pop()][0].current if len(temperature_keys) > 0 and temperatures[temperature_keys.pop()][0].current is not None else 0
+
+        return {
+            "usage": round(psutil.cpu_percent()),
+            "temp": round(temperature),
+            "memory": round(psutil.virtual_memory().percent),
+            "flags": 0,
+        }
+
+    def get_info(self):
+        return {
+            "python_version": self.python_version(),
+            "machine": self.machine(),
+            "os": self.os(),
+            "is_ethernet": self.is_ethernet(),
+            "ssid": self.ssid(),
+            "hostname": self.hostname(),
+            "local_ip": self.local_ip(),
+            "core_count": self.core_count(),
+            "total_memory": self.total_memory(),
+        }
 
     def python_version(self) -> str:
-        return platform.python_version() 
-
+        """
+        Returns the Python version.
+        """
+        return platform.python_version()
+    
     def __get_cpu_model_linux(self) -> Optional[str]:
         info_path = "/proc/cpuinfo"
 
@@ -68,8 +100,6 @@ def machine(self) -> str:
 
             if not model is None:
                 return model
-    
-        return platform.machine()
 
     def os(self) -> str:
         return platform.system()
@@ -125,4 +155,15 @@ def core_count(self) -> Optional[int]:
 
     def total_memory(self) -> int:
         return psutil.virtual_memory().total
-        
+    
+    def restart(self):
+        if self.os() == "Linux":
+            os.system("sudo reboot")
+        elif self.os() == "Windows":
+            os.system("shutdown /r /t 1")
+
+    def shutdown(self):
+        if self.os() == "Linux":
+            os.system("sudo shutdown now")
+        elif self.os() == "Windows":
+            os.system("shutdown /s /t 1")
\ No newline at end of file
diff --git a/simplyprint_ws_client/helpers/sentry.py b/simplyprint_ws_client/helpers/sentry.py
new file mode 100644
index 0000000..81edbef
--- /dev/null
+++ b/simplyprint_ws_client/helpers/sentry.py
@@ -0,0 +1,40 @@
+from typing import Optional
+
+import sentry_sdk
+
+from ..const import IS_TESTING, VERSION
+
+
+class Sentry:
+    """
+    Configuration object for client information.
+    """
+    
+    client: Optional[str] = None
+    client_version: Optional[str] = None
+    sentry_dsn: Optional[str] = None
+    development: bool = IS_TESTING
+
+    def initialize_sentry(self, unique_id: Optional[str] = None):
+        if self.sentry_dsn is None:
+            return
+    
+        if sentry_sdk.Hub.current.client is not None:
+            return
+
+        try:
+            sentry_sdk.init(
+                dsn=self.sentry_dsn,
+                traces_sample_rate=0.05,
+                release=f"{self.info.client}@{self.info.client_version}",
+                environment=("production" if not self.info.development else "development"),
+            )
+
+            sentry_sdk.set_tag("lib_version", VERSION)
+
+            if unique_id is not None:
+                sentry_sdk.set_tag("id", unique_id)
+            
+        except Exception:
+            # Log
+            pass
\ No newline at end of file
diff --git a/simplyprint_ws_client/helpers/temperature.py b/simplyprint_ws_client/helpers/temperature.py
new file mode 100644
index 0000000..8b363e8
--- /dev/null
+++ b/simplyprint_ws_client/helpers/temperature.py
@@ -0,0 +1,27 @@
+from typing import Optional
+
+from traitlets import Float
+
+from ..events.client_events import TemperatureEvent
+from ..state import ClientState, to_event
+
+
+@to_event(TemperatureEvent, "actual", "target")
+class Temperature(ClientState):
+    actual: Optional[float] = Float()
+    target: Optional[float] = Float(allow_none=True)
+
+    def __eq__(self, other) -> bool:
+        if not isinstance(other, Temperature):
+            return False
+
+        return self.actual == other.actual and self.target == other.target
+
+    def is_heating(self) -> bool:
+        if self.target is None:
+            return False
+
+        return round(self.actual) != round(self.target)
+    
+    def to_list(self):
+        return [round(self.actual)] + ([round(self.target)] if self.target is not None else [])
\ No newline at end of file
diff --git a/simplyprint_ws_client/instance/__init__.py b/simplyprint_ws_client/instance/__init__.py
new file mode 100644
index 0000000..263398d
--- /dev/null
+++ b/simplyprint_ws_client/instance/__init__.py
@@ -0,0 +1,3 @@
+from .instance import Instance, TClient, TConfig
+from .single_printer import SinglePrinter
+from .multi_printer import MultiPrinter
\ No newline at end of file
diff --git a/simplyprint_ws_client/instance/instance.py b/simplyprint_ws_client/instance/instance.py
new file mode 100644
index 0000000..4a28b11
--- /dev/null
+++ b/simplyprint_ws_client/instance/instance.py
@@ -0,0 +1,304 @@
+import asyncio
+import logging
+import threading
+from abc import ABC, abstractmethod
+from asyncio import AbstractEventLoop
+import time
+from typing import Any, Awaitable, Generic, Iterable, List, Optional, Tuple, TypeVar, Union, Callable
+
+from ..client import Client, ClientConfigChangedEvent
+from ..config.config import Config
+from ..config.manager import ConfigManager
+from ..connection import (Connection, ConnectionConnectedEvent,
+                          ConnectionDisconnectEvent,
+                          ConnectionEventReceivedEvent,
+                          ConnectionReconnectEvent)
+
+from ..events.client_events import ClientEvent
+from ..events.demands import DemandEvent
+from ..events.server_events import ServerEvent
+from ..events.event_bus import Event, EventBus
+from ..helpers.sentry import Sentry
+
+TClient = TypeVar("TClient", bound=Client)
+TConfig = TypeVar("TConfig", bound=Config)
+
+class InstanceException(Exception):
+    ...
+
+class Instance(ABC, Generic[TClient, TConfig]):
+    """
+
+    Abstract instance of a SimplyPrint client. This class
+    contains the big picture stuff for controlling any amounts
+    of clients, excluding the management of the clients themselves.
+
+    The general workflow and responsibilities of an instance is as follows:
+
+    1. Maintain connection to websocket server when applicable.
+    2. Consume events from the websocket server.
+    3. Broker events to the appropriate clients.
+    4. Broker events from clients to the websocket server.
+    5. Progress client "tick" when applicable.
+
+
+    """
+
+    logger = logging.getLogger("instance")
+
+    loop: AbstractEventLoop
+    sentry: Optional[Sentry] = None
+    connection: Connection
+    config_manager: ConfigManager[TConfig]
+
+    allow_setup = False
+    reconnect_timeout: float = 5.0
+    tick_rate: float = 1.0
+
+    _stop_event: threading.Event
+
+    event_bus: EventBus[Event]
+
+    # Queues to synchronize events between threads / coroutines
+    server_event_backlog: List[Tuple[ConnectionEventReceivedEvent]]
+    client_event_backlog: List[Tuple[TClient, ClientEvent]]
+
+    def __init__(self, loop: AbstractEventLoop, config_manager: ConfigManager[TConfig], allow_setup = False, reconnect_timeout = 5.0, tick_rate = 1.0) -> None:
+        self.loop = loop or asyncio.get_event_loop()
+        self.connection = Connection(self.loop)
+        self.config_manager = config_manager
+
+        self.connection.event_bus.on(ConnectionConnectedEvent, self.on_connect)
+        self.connection.event_bus.on(ConnectionDisconnectEvent, self.on_disconnect)
+        self.connection.event_bus.on(ConnectionReconnectEvent, self.on_reconnect)
+        self.connection.event_bus.on(ConnectionEventReceivedEvent, self.on_recieved_event)
+
+        self._stop_event = threading.Event()
+        self.event_bus = EventBus()
+        
+        self.server_event_backlog = []
+        self.client_event_backlog = []
+
+        self.allow_setup = allow_setup
+        self.reconnect_timeout = reconnect_timeout
+        self.tick_rate = tick_rate
+
+        self.event_bus.on_generic(ServerEvent, self.on_event)
+
+    async def run(self) -> None:        
+        if self.sentry and self.sentry.sentry_dsn is not None:
+            self.sentry.initialize_sentry()
+        
+        if not self.connection.is_connected():
+            await self.connect()
+
+        await asyncio.gather(
+            self.poll_events(),
+            self.consume_clients()
+        )
+
+    def stop(self) -> None:
+        self._stop_event.set()
+        self.loop.stop()
+    
+    async def consume_clients(self):
+        while not self._stop_event.is_set():
+            dt = time.time()
+
+            try:
+                if not self.connection.is_connected():
+                    await asyncio.sleep(self.reconnect_timeout)
+                    continue
+
+                for client in self.get_clients():
+                    await self.consume_client(client)
+
+                await asyncio.sleep(max(0, self.tick_rate - (time.time() - dt)))
+                
+            except Exception as e:
+                self.logger.exception(e)
+
+    async def consume_client(self, client: TClient):
+        # Only consume connected clients
+        if not client.connected:
+            return
+
+        await client.tick()
+        await client.consume_state()
+
+    async def poll_events(self) -> None:
+        while not self._stop_event.is_set():
+            if not self.connection.is_connected():
+                await asyncio.sleep(self.reconnect_timeout)
+                continue
+
+            await self.connection.poll_event()
+
+    async def connect(self) -> None:
+        if not self.should_connect():
+            self.logger.info("No clients to connect - not connecting")
+            return
+        
+        if self.connection.is_connected():
+            self.logger.info("Already connected - not connecting, call connection connect manually to force a reconnect")
+            return
+
+        await self.connection.connect()
+
+    async def on_disconnect(self, _: ConnectionDisconnectEvent):
+        # Mark all printers as disconnected
+        for client in self.get_clients():
+            client.connected = False
+
+        self.logger.info(f"Disconnected from server - reconnecting in {self.reconnect_timeout} seconds")
+        await asyncio.sleep(self.reconnect_timeout)
+        await self.connect()
+
+    async def on_recieved_event(self, event: ConnectionEventReceivedEvent):
+        """
+        Events received by SimplyPrint to be ingested.
+        """
+        client = self.get_client(Config(id=event.for_client))
+
+        if not client and hasattr(event.event, "unique_id"):
+            client = self.get_client(Config(unique_id=event.event.unique_id))
+
+        if not client:
+            self.server_event_backlog.append((event,))
+            return
+        
+        await self.event_bus.emit(event.event, client)
+
+    async def on_client_config_changed(self, client: TClient):
+        """ 
+        When a client config is changed, persist it to disk.
+        """
+
+        self.config_manager.flush(client.config)
+
+    async def register_client(self, client: TClient):
+        """
+        Adds some default event handling for a client.
+        should be called once.
+        """
+
+        if self.has_client(client):
+            raise InstanceException("Client already registered")
+
+        self.config_manager.persist(client.config)
+
+        # Capture generic client events to be sent to SimplyPrint
+        async def on_client_event(event: ClientEvent):
+            if not client.connected:
+                self.client_event_backlog.append((client, event))
+                return
+
+            await self.on_client_event(client, event)
+        
+        client.event_bus.on_generic(ClientEvent, on_client_event)
+
+        async def on_client_config_changed(_: ClientConfigChangedEvent):
+            await self.on_client_config_changed(client)
+
+        # Listen to custom events for internal use.
+        client.event_bus.on(ClientConfigChangedEvent, on_client_config_changed)
+
+        await self.add_client(client)
+        await self.consume_backlog(self.server_event_backlog, self.on_recieved_event)
+        
+        if not self.connection.is_connected():
+            await self.connect()
+
+    async def consume_backlog(self, backlog: List[Any], consumer: Callable[[Any], Awaitable[None]]):
+        """
+        Consumes any events that were received before the client was registered
+        this will push elements still not consumed to the end of the list
+        so we keep track of the seek pointer
+        """
+
+        seek_pointer = 0
+        seek_until = len(backlog)
+
+        while seek_pointer < seek_until:
+            args = backlog.pop(0)
+
+            await consumer(*args)
+
+            seek_pointer += 1
+
+    @abstractmethod
+    def get_clients(self) -> Iterable[TClient]:
+        """
+        Returns an iterable of all clients that should be considered "active".
+        """
+        ...
+
+    @abstractmethod
+    def has_client(self, client: TClient) -> bool:
+        """
+        Returns true if the instance has a client.
+        """
+        ...
+
+    @abstractmethod
+    def get_client(self, config: TConfig) -> Optional[TClient]:
+        """
+        Returns a client instance for the given config, potentially partial config.
+        """
+        ...
+
+    @abstractmethod
+    async def add_client(self, client: TClient) -> None:
+        """
+        Adds a client to the instance.
+        """
+        ...
+
+    @abstractmethod
+    async def remove_client(self, client: TClient) -> None:
+        """
+        Removes a client from the instance.
+        """
+        ...
+
+    @abstractmethod
+    def should_connect(self) -> bool:
+        """
+        Should return true if the instance should connect to the server.
+        """
+        ...
+
+    @abstractmethod
+    async def on_connect(self, _: ConnectionConnectedEvent):
+        """
+        Called the first time the connection is established.
+
+        Here the appropriate startup logic for an instance can
+        be implemented.
+        """
+        ...
+
+    @abstractmethod
+    async def on_reconnect(self, _: ConnectionReconnectEvent):
+        """
+        Called when the connection is re-established.
+
+        Here it is the instances job to re-add any clients to the connection
+        """
+        ...
+
+    @abstractmethod
+    async def on_event(self, client: TClient, event: Union[ServerEvent, DemandEvent]):
+        """
+        Called when a client event is received.
+
+        Here the instance can implement custom event handling.
+        """
+        ...
+
+    @abstractmethod
+    async def on_client_event(self, client: TClient, event: ClientEvent):
+        """ 
+        Events a client wants to send to SimplyPrint.
+        """
+        pass
\ No newline at end of file
diff --git a/simplyprint_ws_client/instance/multi_printer.py b/simplyprint_ws_client/instance/multi_printer.py
new file mode 100644
index 0000000..3354d93
--- /dev/null
+++ b/simplyprint_ws_client/instance/multi_printer.py
@@ -0,0 +1,145 @@
+from asyncio import AbstractEventLoop
+from enum import Enum
+from typing import Iterable, Optional, Set, Union
+
+from ..client import Client
+from ..config.config import Config
+from ..config.manager import ConfigManager
+from ..connection import ConnectionConnectedEvent, ConnectionReconnectEvent
+from ..const import TEST_WEBSOCKET_URL
+from ..events import DemandEvent, ServerEvent
+from ..events.client_events import (ClientEvent, MachineDataEvent,
+                                    StateChangeEvent)
+from ..events.server_events import MultiPrinterAddResponseEvent
+from .instance import Instance, TClient, TConfig
+
+
+class MultiPrinterException(RuntimeError):
+    pass
+
+class MultiPrinterClientEvents(Enum):
+    ADD_PRINTER = "add_connection"
+    REMOVE_PRINTER = "remove_connection"
+
+class MultiPrinterAddPrinterEvent(ClientEvent):
+    event_type = MultiPrinterClientEvents.ADD_PRINTER
+
+    def __init__(self, config: Config, allow_setup: bool = False) -> None:
+        super().__init__(None, None, {
+            "pid": config.id,
+            "token": config.token,
+            "unique_id": config.get_unique_id(),
+            "allow_setup": allow_setup,
+            "public_ip": config.public_ip if hasattr(config, "public_ip") else None
+        })
+
+
+class MultiPrinterRemovePrinterEvent(ClientEvent):
+    event_type = MultiPrinterClientEvents.REMOVE_PRINTER
+
+    def __init__(self, config: Config) -> None:
+        super().__init__(None, None, {
+            "pid": config.id,
+        })
+
+class MultiPrinter(Instance[TClient, TConfig]):
+    clients: Set[Client[TConfig]]
+    # List of unique ids pending a response from add connection
+    pending_unique_set: Set[str]
+
+    def __init__(self, loop: AbstractEventLoop, config_manager: ConfigManager[TConfig], **kwargs) -> None:
+        super().__init__(loop, config_manager, **kwargs)
+
+        self.event_bus.on(MultiPrinterAddResponseEvent, self.on_printer_added_response)
+
+        self.connection.set_url(f"{TEST_WEBSOCKET_URL}/mp/0/0")
+        self.clients = set()
+        self.pending_unique_set = set()
+
+
+    async def add_client(self, client: TClient) -> None:
+        self.clients.add(client)
+
+        if not self.connection.is_connected():
+            return
+        
+        await self._send_add_printer(client)
+
+    async def remove_client(self, client: TClient) -> None:
+        self.clients.remove(client)
+
+        if not self.connection.is_connected():
+            return
+
+        await self._send_remove_printer(client)
+
+    def get_clients(self) -> Iterable[TClient]:
+        return self.clients
+
+    def get_client(self, config: TConfig) -> Optional[TClient]:
+        for client in self.clients:
+            if not client.config.partial_eq(config):
+                continue
+
+            return client
+
+    def has_client(self, client: TClient) -> bool:
+        return client in self.clients
+
+    def should_connect(self) -> bool:
+        return len(self.clients) > 0
+
+    async def on_client_event(self, client: TClient, event: ClientEvent):
+        if not client.connected:
+            self.client_event_backlog.append((client, event))
+            return
+
+        await self.connection.send_event(event)
+
+    async def on_event(self, client: TClient, event: Union[ServerEvent, DemandEvent]):
+        await client.event_bus.emit(event)
+
+    async def on_printer_added_response(self, client: TClient, event: MultiPrinterAddResponseEvent):
+        self.pending_unique_set.remove(client.config.get_unique_id())
+        
+        if event.status:
+            client.config.id = event.printer_id
+            self.config_manager.flush(client.config)
+
+            # Mark certain events to always be sent to the server
+            client.printer.mark_event_as_dirty(MachineDataEvent)
+            client.printer.mark_event_as_dirty(StateChangeEvent)
+
+            await self.consume_backlog(self.server_event_backlog, self.on_recieved_event)
+        else:
+            self.clients.remove(client)
+
+        # Do not propegate event further.
+        event.stop_event()
+
+    async def on_connect(self, _: ConnectionConnectedEvent):
+        self.pending_unique_set.clear()
+
+        for client in self.clients:
+            if client.connected:
+                continue
+
+            await self._send_add_printer(client)
+
+    async def on_reconnect(self, _: ConnectionReconnectEvent):
+        self.pending_unique_set.clear()
+
+        for client in self.clients:
+            client.connected = False
+            await self._send_add_printer(client)
+
+    async def _send_add_printer(self, client: TClient):
+        if client.config.get_unique_id() in self.pending_unique_set:
+            raise MultiPrinterException(
+                f"Cannot add printer with unique id {client.config.get_unique_id()} as it is already in use")
+
+        await self.connection.send_event(MultiPrinterAddPrinterEvent(client.config, self.allow_setup))
+        self.pending_unique_set.add(client.config.get_unique_id())
+
+    async def _send_remove_printer(self, client: TClient):
+        await self.connection.send_event(MultiPrinterRemovePrinterEvent(client.config))
\ No newline at end of file
diff --git a/simplyprint_ws_client/instance/single_printer.py b/simplyprint_ws_client/instance/single_printer.py
new file mode 100644
index 0000000..5fdb284
--- /dev/null
+++ b/simplyprint_ws_client/instance/single_printer.py
@@ -0,0 +1,51 @@
+from typing import Iterable, Optional, Union
+
+from simplyprint_ws_client.const import TEST_WEBSOCKET_URL
+
+from ..client import Client
+from ..connection import ConnectionConnectedEvent, ConnectionReconnectEvent
+from ..events import DemandEvent, ServerEvent
+from ..events.client_events import ClientEvent
+from .instance import Instance, TClient, TConfig
+
+
+class SinglePrinter(Instance[TClient, TConfig]):
+    client: Optional[Client[TConfig]] = None
+
+    async def add_client(self, client: TClient) -> None:
+        self.connection.set_url(f"{TEST_WEBSOCKET_URL}/p/{client.config.id}/{client.config.token}")
+        self.client = client
+
+    def get_client(self, _: TConfig) -> Union[TClient, None]:
+        return self.client
+
+    def get_clients(self) -> Iterable[TClient]:
+        return [ self.client ]
+
+    def has_client(self, client: TClient) -> bool:
+        return self.client == client
+
+    async def remove_client(self, client: TClient) -> None:
+        if not self.has_client(client):
+            return 
+        
+        self.client = None
+
+    def should_connect(self) -> bool:
+        return not self.client is None
+    
+    async def on_client_event(self, client: TClient, event: ClientEvent):
+        if not client.connected:
+            self.client_event_backlog.append((client, event))
+            return
+
+        await self.connection.send_event(event)
+
+    async def on_event(self, client: TClient, event: Union[ServerEvent, DemandEvent]):
+        await client.event_bus.emit(event)
+
+    async def on_connect(self, _: ConnectionConnectedEvent):
+        await self.consume_backlog(self.client_event_backlog, self.on_client_event)
+
+    async def on_reconnect(self, _: ConnectionReconnectEvent):
+        ...
\ No newline at end of file
diff --git a/simplyprint_ws_client/printer_state.py b/simplyprint_ws_client/printer_state.py
deleted file mode 100644
index f2698a9..0000000
--- a/simplyprint_ws_client/printer_state.py
+++ /dev/null
@@ -1,97 +0,0 @@
-from enum import Enum
-from typing import Optional, List, Dict, Any
-
-class Temperature:
-    def __init__(self, actual: float = 0.0, target: Optional[float] = None):
-        self.actual: float = actual
-        self.target: Optional[float] = target
-
-    def __eq__(self, other) -> bool:
-        if not isinstance(other, Temperature):
-            return False
-
-        return self.actual == other.actual and self.target == other.target
-
-    def heating(self) -> bool:
-        if self.target is None:
-            return False
-
-        return round(self.actual) != round(self.target)
-
-    def to_list(self) -> List[int]:
-        out = [round(self.actual)]
-
-        if not self.target is None:
-            out.append(round(self.target))
-
-        return out
-
-class PrinterStatus(Enum):
-    OPERATIONAL = "operational"
-    PRINTING = "printing"
-    OFFLINE = "offline"
-    PAUSED = "paused"
-    PAUSING = "pausing"
-    CANCELLING = "cancelling"
-    ERROR = "error"
-    NOT_READY = "not_ready"
-
-class Display:
-    def __init__(self, data: Dict[str, Any]):
-        self.enabled: bool = bool(data.get("enabled", 0))
-        self.branding: bool = bool(data.get("branding", 0))
-        self.while_printing_type: int = data.get("while_printing_type", 0)
-        self.show_status: bool = bool(data.get("show_status", 0))
-
-class PrinterSettings:
-    def __init__(self, data: Dict[str, Any] = {}):
-        self.has_psu: bool = bool(data.get("has_psu", 0))
-        self.has_filament_sensor: bool = bool(data.get("has_filament_sensor", 0))
-        self.display: Display = Display(data.get("display", {}))
-
-class PrinterFirmware:
-    name: Optional[str] = None
-    version: Optional[str] = None
-    date: Optional[str] = None
-    link: Optional[str] = None
-
-    def dict(self) -> Dict[str, Any]:
-        return {
-            "firmware": self.name,
-            "firmware_version": self.version,
-            "firmware_date": self.date,
-            "firmware_link": self.link
-        }
-
-class PrinterCpuFlag(Enum):
-    NONE = 0
-    THROTTLED = 1
-
-class Printer:
-    connected: bool = False
-    is_set_up: bool = False
-    status: PrinterStatus = PrinterStatus.OFFLINE
-
-    ambient_temperature: Optional[float] = None 
-
-    tool_temperatures: List[Temperature] = []
-    bed_temperature: Optional[Temperature] = None
-
-    server_tool_temperatures: List[Temperature] = []
-    server_bed_temperature: Optional[Temperature] = None
-
-    name: Optional[str] = None
-
-    settings: PrinterSettings = PrinterSettings()
-    firmware: PrinterFirmware = PrinterFirmware()
-    current_display_message: Optional[str] = None
-
-    def is_heating(self) -> bool:
-        for tool in self.tool_temperatures:
-            if tool.heating():
-                return True
-
-        if self.bed_temperature is not None and self.bed_temperature.heating():
-            return True
-
-        return False
diff --git a/simplyprint_ws_client/state/__init__.py b/simplyprint_ws_client/state/__init__.py
new file mode 100644
index 0000000..33670ba
--- /dev/null
+++ b/simplyprint_ws_client/state/__init__.py
@@ -0,0 +1,2 @@
+from .always import Always
+from .root_state import DEFAULT_EVENT, ClientState, RootState, to_event
diff --git a/simplyprint_ws_client/state/always.py b/simplyprint_ws_client/state/always.py
new file mode 100644
index 0000000..9139380
--- /dev/null
+++ b/simplyprint_ws_client/state/always.py
@@ -0,0 +1,64 @@
+from typing import Any
+from traitlets import HasTraits, TraitType
+
+class Always(TraitType):
+    """ 
+    Wrap a trait type with custom setter logic.
+    """
+
+    def __init__(self, trait: TraitType):
+        self._trait = trait
+
+        # Loop over all functions in the trait
+        for name in dir(trait):
+            # If it's a function, and not a private function
+            if name in ["set"] or name.startswith("__"):
+                continue
+
+            value = getattr(trait, name)
+
+            is_callable = callable(value)
+
+            if not is_callable and not hasattr(self, name):
+                setattr(self, name, value)
+
+            if not is_callable:
+                continue
+
+            # Make a wrapper so self is _trait
+            def make_wrapper(trait, name):
+                def wrapper(*args, **kwargs):
+                    return getattr(trait, name)(*args, **kwargs)
+
+                return wrapper
+
+            # Set the wrapper to the trait
+            setattr(self, name, make_wrapper(trait, name))
+
+    @property
+    def name(self):
+        """ 
+        A traitlets name is dynamically generated so we make it accessible
+        as a property.
+        """
+        return self._trait.name
+
+    def set(self, obj: HasTraits, value: Any):
+        """ 
+        Re-implementation of TraitType.set from 
+        https://github.com/ipython/traitlets/blob/main/traitlets/traitlets.py#L689
+
+        to always notify observers when a value has been set
+        not just when it has new information.
+        """
+        new_value = self._validate(obj, value)
+        assert self.name is not None
+        try:
+            old_value = obj._trait_values[self.name]
+        except KeyError:
+            old_value = self.default_value
+
+        obj._trait_values[self.name] = new_value
+
+        # Always notify
+        obj._notify_trait(self.name, old_value, new_value)
\ No newline at end of file
diff --git a/simplyprint_ws_client/state/models.py b/simplyprint_ws_client/state/models.py
new file mode 100644
index 0000000..bbb99ff
--- /dev/null
+++ b/simplyprint_ws_client/state/models.py
@@ -0,0 +1,8 @@
+from typing import Optional, Union
+from traitlets import Any, HasTraits, Integer, Unicode
+
+class MaterialModel(HasTraits):
+    type: Optional[Union[str, int]] = Any(None, allow_none=True)
+    color: Optional[str] = Unicode(None, allow_none=True)
+    hex: Optional[str] = Unicode(None, allow_none=True)
+    ext: Optional[int] = Integer(None, allow_none=True)
\ No newline at end of file
diff --git a/simplyprint_ws_client/state/printer.py b/simplyprint_ws_client/state/printer.py
new file mode 100644
index 0000000..6e2a0e3
--- /dev/null
+++ b/simplyprint_ws_client/state/printer.py
@@ -0,0 +1,246 @@
+from enum import Enum
+from typing import List, Optional
+
+from traitlets import Bool
+from traitlets import Enum as TraitletsEnum
+from traitlets import Float, Instance, Int, Integer
+from traitlets import List as TraitletsList
+from traitlets import Unicode, observe
+
+from ..events.client_events import *
+from ..helpers.ambient_check import AmbientTemperatureState
+from ..helpers.intervals import Intervals
+from ..helpers.temperature import Temperature
+from .always import Always
+from .models import MaterialModel
+from .root_state import ClientState, RootState, to_event
+
+
+class PrinterCpuFlag(Enum):
+    NONE = 0
+    THROTTLED = 1
+
+
+@to_event(CpuInfoEvent)
+class CpuInfoState(ClientState):
+    usage: float = Float()
+    temp: float = Float()
+    memory: float = Float()
+
+
+class PrinterStatus(Enum):
+    OPERATIONAL = "operational"
+    PRINTING = "printing"
+    OFFLINE = "offline"
+    PAUSED = "paused"
+    PAUSING = "pausing"
+    CANCELLING = "cancelling"
+    ERROR = "error"
+    NOT_READY = "not_ready"
+
+
+class FileProgressState(Enum):
+    DOWNLOADING = "downloading"
+    ERROR = "error"
+    PENDING = "pending"
+    STARTED = "started"
+    READY = "ready"
+
+
+@to_event(FileProgressEvent)
+class PrinterFileProgressState(ClientState):
+    state: Optional[FileProgressState] = TraitletsEnum(FileProgressState)
+    percent: Optional[float] = Float()
+    message: Optional[str] = Unicode()  # Typically error message
+
+
+@to_event(MachineDataEvent)
+class PrinterInfoData(ClientState):
+    ui = Unicode()
+    ui_version = Unicode()
+    api = Unicode()
+    api_version = Unicode()
+    machine = Unicode()
+    os = Unicode()
+    sp_version = Unicode()
+    python_version = Unicode()
+    is_ethernet = Bool()
+    ssid = Unicode(allow_none=True)
+    local_ip = Unicode()
+    hostname = Unicode()
+    core_count = Int()
+    total_memory = Int()
+    mac = Unicode()
+
+class PrinterDisplaySettings(ClientState):
+    enabled: bool = Bool()
+    branding: bool = Bool()
+    while_printing_type: int = Int()
+    show_status: bool = Bool()
+
+class PrinterSettings(ClientState):
+    has_psu: bool = Bool()
+    has_filament_sensor: bool = Bool()
+
+
+@to_event(FirmwareEvent)
+class PrinterFirmware(ClientState):
+    name: Optional[str] = Unicode()
+    name_raw: Optional[str] = Unicode()
+    version: Optional[str] = Unicode()
+    date: Optional[str] = Unicode()
+    link: Optional[str] = Unicode()
+
+
+@to_event(FirmwareWarningEvent)
+class PrinterFirmwareWarning(ClientState):
+    check_name: Optional[str] = Unicode()
+    warning_type: Optional[str] = Unicode()
+    severity: Optional[str] = Unicode()
+    url: Optional[str] = Unicode()
+
+
+class PrinterFilamentSensorEnum(Enum):
+    LOADED = "loaded"
+    RUNOUT = "runout"
+
+
+@to_event(FilamentSensorEvent)
+class PrinterFilamentSensorState(ClientState):
+    state: Optional[PrinterFilamentSensorEnum] = TraitletsEnum(
+        PrinterFilamentSensorEnum)
+
+
+@to_event(PowerControllerEvent)
+class PrinterPSUState(ClientState):
+    on: bool = Bool()
+
+@to_event(JobInfoEvent)
+class JobInfoState(ClientState):
+    progress: Optional[float] = Float()
+    initial_estimate: Optional[float] = Float()
+    layer: Optional[int] = Int()
+    time: Optional[float] = Float()  # Time left in seconds
+    filament: Optional[float] = Float()  # Filament usage
+    filename: Optional[str] = Unicode()
+
+    started: bool = Always(Bool())
+    finished: bool = Always(Bool())
+    cancelled: bool = Always(Bool())
+    failed: bool = Always(Bool())
+
+    @observe("started", "finished", "cancelled", "failed")
+    def _on_job_state_change(self, change):        # If one changes, set the others to false
+        if not change["new"]:
+            return
+        
+        for key in ["started", "finished", "cancelled", "failed"]:
+            # Only set "True" values to "False"
+            # As undefined values can stay undefined
+            if key != change["name"] and getattr(self, key):
+                setattr(self, key, False)
+
+    delay: Optional[float] = Float()
+
+    # Not yet implemented
+    # ai: List[int] = TraitletsList(Int())
+
+
+@to_event(LatencyEvent, "pong")
+class PingPongState(ClientState):
+    ping: Optional[float] = Float()  # Timestamp when ping was sent
+    pong: Optional[float] = Float()  # Timestamp when pong was received
+
+
+@to_event(WebcamStatusEvent, "connected")
+class WebcamState(ClientState):
+    connected: bool = Bool()
+
+@to_event(WebcamEvent)
+class WebcamSettings(ClientState):
+    flipH: bool = Bool()
+    flipV: bool = Bool()
+    rotate90: bool = Bool()
+
+@to_event(StateChangeEvent, "status")
+@to_event(ConnectionEvent, "connected")
+@to_event(ToolEvent, "active_tool")
+@to_event(MaterialDataEvent, "material_data")
+class PrinterState(RootState):
+    status: PrinterStatus = TraitletsEnum(PrinterStatus)
+    current_display_message: Optional[str] = Unicode()
+
+    bed_temperature: Temperature = Instance(Temperature)
+    tool_temperatures: List[Temperature] = TraitletsList(Instance(Temperature))
+
+    ambient_temperature: AmbientTemperatureState = Instance(
+        AmbientTemperatureState)
+    
+    intervals: Intervals = Instance(Intervals)
+
+    info: PrinterInfoData = Instance(PrinterInfoData)
+    cpu_info: CpuInfoState = Instance(CpuInfoState)
+    job_info: JobInfoState = Instance(JobInfoState)
+    psu_info: PrinterPSUState = Instance(PrinterPSUState)
+    settings: PrinterSettings = PrinterSettings()
+    firmware: PrinterFirmware = PrinterFirmware()
+    ping_pong: PingPongState = Instance(PingPongState)
+    webcam_info: WebcamState = Instance(WebcamState)
+    display_settings: PrinterDisplaySettings = PrinterDisplaySettings()
+    file_progress: PrinterFileProgressState = Instance(
+        PrinterFileProgressState)
+    filament_sensor: PrinterFilamentSensorState = Instance(
+        PrinterFilamentSensorState)
+    webcam_settings: WebcamSettings = Instance(WebcamSettings)
+
+    active_tool: Optional[int] = Integer(None, allow_none=True)
+    material_data: List[MaterialModel] = TraitletsList(Instance(MaterialModel))
+
+    def __init__(self, extruder_count: int = 1) -> None:
+        super().__init__(
+            status=PrinterStatus.OFFLINE,
+            bed_temperature=Temperature(),
+            tool_temperatures=[Temperature() for _ in range(extruder_count)],
+            ambient_temperature=AmbientTemperatureState(),
+            intervals=Intervals(),
+            info=PrinterInfoData(),
+            settings=PrinterSettings(),
+            display_settings=PrinterDisplaySettings(),
+            firmware=PrinterFirmware(),
+            cpu_info=CpuInfoState(),
+            webcam_info=WebcamState(),
+            webcam_settings=WebcamSettings(),
+            job_info=JobInfoState(),
+            psu_info=PrinterPSUState(),
+            ping_pong=PingPongState(),
+            file_progress=PrinterFileProgressState(),
+            filament_sensor=PrinterFilamentSensorState(),
+            material_data=[MaterialModel() for _ in range(extruder_count)],
+        )
+
+    def set_extruder_count(self, count: int) -> None:
+        if count > len(self.tool_temperatures):
+            for _ in range(count - len(self.tool_temperatures)):
+                self.tool_temperatures.append(Temperature())
+        else:
+            self.tool_temperatures = self.tool_temperatures[:count]
+
+        if self.active_tool is not None and self.active_tool >= count:
+            self.active_tool = None
+
+        if count > len(self.material_data) :
+            for _ in range(count - len(self.material_data)):
+                self.material_data.append(MaterialModel())
+        else:
+            self.material_data = self.material_data[:count]
+       
+
+    def is_heating(self) -> bool:
+        for tool in self.tool_temperatures:
+            if tool.is_heating():
+                return True
+
+        if self.bed_temperature is not None and self.bed_temperature.is_heating():
+            return True
+
+        return False
diff --git a/simplyprint_ws_client/state/root_state.py b/simplyprint_ws_client/state/root_state.py
new file mode 100644
index 0000000..67f9e86
--- /dev/null
+++ b/simplyprint_ws_client/state/root_state.py
@@ -0,0 +1,152 @@
+from collections import OrderedDict
+from typing import Any, Dict, List, Optional
+from typing import OrderedDict as OrderedDictType
+from typing import Set, Type
+
+from traitlets import HasTraits
+
+from ..events.client_events import ClientEvent
+
+DEFAULT_EVENT = "__default__"
+
+class ClientState(HasTraits):
+    """
+    A class that represents a state that can be sent to the client.
+
+    Event map is a dictionary that maps the name of a trait to the event that should be sent when it changes.
+
+    The key __default__ is reserved for the default event that should be sent when any trait changes.
+    """
+    _event_mapping: Dict[str, Any]
+
+def to_event(client_event: Type[ClientEvent], *names: str):
+    """ 
+    Map events from a ClientState class to its _event_mapping
+    """
+
+    def wrapper(cls: ClientState):
+        if not issubclass(cls, ClientState):
+            raise ValueError("to_event can only be used on subclasses of ClientState")
+
+        if not hasattr(cls, "_event_mapping"):
+            cls._event_mapping = {}
+ 
+        if not names:
+            cls._event_mapping[DEFAULT_EVENT] = client_event
+            return cls
+        
+        for name in names:
+            cls._event_mapping[name] = client_event
+
+        return cls 
+
+    return wrapper
+
+class RootState(ClientState):
+    """
+    A class that represents the root state of a client. Handles reactive updates and dirty checking.
+
+    Every time a state is changed, a lookup in the event map is performed to find the corresponding event,
+    afterwards a dirty event is added to the dirty set.
+
+    Then we can iterate over the dirty set and build the corresponding events which on generate_data checks the changed fields
+    to decide which data has changed and needs to be sent.
+    """
+
+    _dirty: OrderedDictType[Type[ClientEvent], None]
+    _changed_fields: Dict[int, Set[str]]
+
+    def __init__(self, **kwargs):
+        super().__init__()
+
+        self._dirty = OrderedDict()
+        self._changed_fields = dict()
+
+        self._changed_fields[id(self)] = set()
+        self.observe(self._notify_change)
+        
+        for field, value in kwargs.items():
+            setattr(self, field, value)
+
+            if isinstance(value, HasTraits):
+                value.observe(self._notify_change)
+                self._changed_fields[id(value)] = set()
+            
+            # TODO implement traitlets list container
+            elif isinstance(value, list):
+                for item in value:
+                    if not isinstance(item, HasTraits):
+                        continue
+
+                    item.observe(self._notify_change)
+                    self._changed_fields[id(item)] = set()
+
+    def _notify_change(self, change):
+        """Will trigger when state fields are updated, marking corresponding events as dirty"""
+        owner = change['owner']
+
+    
+        # With Always events we need to also check if
+        # the value has changed before setting the changed
+        # fields here.
+        try:
+            has_changed = change['old'] != change['new']
+        except Exception:
+            has_changed = True
+    
+        if has_changed:
+            self._changed_fields[id(owner)].add(change['name'])
+
+        # If the change from old to new is an object, replace the id() in _changed_fields
+        # and add all its fields to it, so to mark the entire object as "changed"
+        # if isinstance(change["old"], HasTraits) and isinstance(change["new"], HasTraits):
+        #    self._changed_fields[id(change["new"])] = change["new"].trait_names()
+        #    del self._changed_fields[id(change["old"])]
+
+        if not hasattr(owner, '_event_mapping'):
+            return
+
+        event = owner._event_mapping.get(change['name']) or owner._event_mapping.get(
+            DEFAULT_EVENT) or self._event_mapping.get(change['name'])
+
+        if event is None:
+            return
+
+        self.mark_event_as_dirty(event)
+    
+    def mark_event_as_dirty(self, event: Type[ClientEvent]):
+        """Mark an event as dirty"""
+        self._dirty[event] = None
+
+    def _build_events(self, for_client: Optional[int] = None):
+        """
+        Generator - creates :class:`ClientEvent` instances from dirty
+        
+        Ensure the ordered "set" is iterated in the same order as it was inserted
+        so we pop from the front.
+        """
+
+        while self._dirty:
+            client_event, _ = self._dirty.popitem(last=False)
+            yield client_event(state=self, for_client=for_client)
+
+    def get_event_types(self) -> List[Type[ClientEvent]]:
+        """Get all event types that are currently dirty"""
+        return list(self._dirty.keys())
+
+    def has_changed(self, obj: HasTraits, name: Optional[str] = None, clear: bool = True):
+        """Check if a field has changed since last update"""
+        if name is None:
+            predicate = len(self._changed_fields[id(obj)]) > 0
+
+            if predicate and clear:
+                self._changed_fields[id(obj)].clear()
+
+            return predicate
+
+        predicate = name in self._changed_fields[id(obj)]
+
+        if predicate and clear:
+            self._changed_fields[id(obj)].remove(name)
+
+        return predicate
diff --git a/simplyprint_ws_client/timer.py b/simplyprint_ws_client/timer.py
deleted file mode 100644
index d97c4a7..0000000
--- a/simplyprint_ws_client/timer.py
+++ /dev/null
@@ -1,146 +0,0 @@
-import asyncio
-
-from datetime import datetime, timedelta
-from typing import Dict
-
-class Intervals:
-    def __init__(self, data: Dict[str, int] = {}):
-        self.job: float = data.get("job", 5000.0) / 1000.0
-        self.temperatures: float = data.get("temps", 5000.0) / 1000.0
-        self.target_temperatures: float = data.get("temps_target", 2500.0) / 1000.0
-        self.cpu: float = data.get("cpu", 30000.0) / 1000.0
-        self.reconnect: float = data.get("reconnect", 1000.0) / 1000.0
-        self.ai: float = data.get("ai", 60000.0) / 1000.0
-        self.ready: float = data.get("ready_message", 60000.0) / 1000.0
-        self.ping: float = data.get("ping", 20000.0) / 1000.0
-
-        self.job_last_update: datetime = datetime.now() - timedelta(seconds=self.job)
-        self.temperatures_last_update: datetime = datetime.now() - timedelta(seconds=self.temperatures)
-        self.target_temperatures_last_update: datetime = datetime.now() - timedelta(seconds=self.target_temperatures)
-        self.cpu_last_update: datetime = datetime.now() - timedelta(seconds=self.cpu)
-        self.reconnect_last_update: datetime = datetime.now() - timedelta(seconds=self.reconnect)
-        self.ai_last_update: datetime = datetime.now() - timedelta(seconds=self.ai)
-        self.ready_last_update: datetime = datetime.now() - timedelta(seconds=self.ready)
-        self.ping_last_update: datetime = datetime.now() - timedelta(seconds=self.ping)
-
-        self.job_updating: bool = False
-        self.temperatures_updating: bool = False
-        self.cpu_updating: bool = False
-        self.reconnect_updating: bool = False
-        self.ai_updating: bool = False
-        self.ready_updating: bool = False
-        self.ping_updating: bool = False
-
-    def update(self, other: "Intervals"):
-        self.job = other.job
-        self.temperatures = other.temperatures
-        self.target_temperatures = other.target_temperatures
-        self.cpu = other.cpu
-        self.reconnect = other.reconnect
-        self.ai = other.ai
-        self.ready = other.ready
-        self.ping = other.ping
-
-    async def sleep_until_job(self):
-        next_update = self.job_last_update + timedelta(seconds=self.job)
-        if datetime.now() > next_update:
-            self.job_last_update = datetime.now()
-            return
-
-        remaining = (next_update - datetime.now()).total_seconds()
-
-        if remaining > 0.0:
-            await asyncio.sleep(remaining) 
-
-        self.job_last_update = datetime.now()
-
-    async def sleep_until_temperatures(self):
-        next_update = self.temperatures_last_update + timedelta(seconds=self.temperatures)
-        if datetime.now() > next_update:
-            self.temperatures_last_update = datetime.now()
-            return
-
-        remaining = (next_update - datetime.now()).total_seconds()
-
-        if remaining > 0.0:
-            await asyncio.sleep(remaining) 
-
-        self.temperatures_last_update = datetime.now()
-
-    async def sleep_until_target_temperatures(self):
-        next_update = self.target_temperatures_last_update + timedelta(seconds=self.target_temperatures)
-        if datetime.now() > next_update:
-            self.target_temperatures_last_update = datetime.now()
-            return
-
-        remaining = (next_update - datetime.now()).total_seconds()
-
-        if remaining > 0.0:
-            await asyncio.sleep(remaining) 
-
-        self.target_temperatures_last_update = datetime.now()
-
-    async def sleep_until_cpu(self):
-        next_update = self.cpu_last_update + timedelta(seconds=self.cpu)
-        if datetime.now() > next_update:
-            self.cpu_last_update = datetime.now()
-            return
-
-        remaining = (next_update - datetime.now()).total_seconds()
-
-        if remaining > 0.0:
-            await asyncio.sleep(remaining) 
-
-        self.cpu_last_update = datetime.now()
-
-    async def sleep_until_reconnect(self):
-        next_update = self.reconnect_last_update + timedelta(seconds=self.reconnect)
-        if datetime.now() > next_update:
-            self.reconnect_last_update = datetime.now()
-            return
-
-        remaining = (next_update - datetime.now()).total_seconds()
-
-        if remaining > 0.0:
-            await asyncio.sleep(remaining) 
-
-        self.reconnect_last_update = datetime.now()
-
-    async def sleep_until_ai(self):
-        next_update = self.ai_last_update + timedelta(seconds=self.ai)
-        if datetime.now() > next_update:
-            self.ai_last_update = datetime.now()
-            return
-
-        remaining = (next_update - datetime.now()).total_seconds()
-
-        if remaining > 0.0:
-            await asyncio.sleep(remaining) 
-
-        self.ai_last_update = datetime.now()
-
-    async def sleep_until_ready(self):
-        next_update = self.ready_last_update + timedelta(seconds=self.ready)
-        if datetime.now() > next_update:
-            self.ready_last_update = datetime.now()
-            return
-
-        remaining = (next_update - datetime.now()).total_seconds()
-
-        if remaining > 0.0:
-            await asyncio.sleep(remaining) 
-
-        self.ready_last_update = datetime.now()
-
-    async def sleep_until_ping(self):
-        next_update = self.ping_last_update + timedelta(seconds=self.ping)
-        if datetime.now() > next_update:
-            self.ping_last_update = datetime.now()
-            return
-
-        remaining = (next_update - datetime.now()).total_seconds()
-
-        if remaining > 0.0:
-            await asyncio.sleep(remaining) 
-
-        self.ping_last_update = datetime.now()
diff --git a/virtual_printer.py b/virtual_printer.py
deleted file mode 100644
index bbdec85..0000000
--- a/virtual_printer.py
+++ /dev/null
@@ -1,99 +0,0 @@
-import asyncio
-import time
-import logging
-import math
-
-from simplyprint_ws_client import * 
-
-def exponential_smoothing(new_value: float, old_value: float, alpha: float, dt: float) -> float:
-    factor = 1 - math.exp(-dt * alpha)
-    return old_value + factor * (new_value - old_value)
-
-class VirtualPrinter(Client):
-    def __init__(self):
-        logging.basicConfig(level=logging.DEBUG)
-
-        self.info.api = "Virtual"
-        self.info.api_version = "0.1"
-        self.info.client = "Virtual"
-        self.info.client_version = "0.0.1-alpha"
-        self.info.sentry_dsn = "https://a5aef1defa83433586dd0cf1c1fffe57@o1102514.ingest.sentry.io/6619552"
-        self.info.development = True
-        self.local_path = "virtual_local"
-
-        self.virtual_tool_temperatures = [Temperature(20.0)]
-        self.virtual_bed_temperature = Temperature(20.0)
-        
-        self.logger = logging.getLogger("simplyprint.VirtualPrinter")
-
-        self.tick_rate: float = 2.0
-        self.use_opencv: bool = True
-
-    async def on_connect(self, _: ConnectEvent):
-        self.logger.info("Connected to server")
-
-    async def on_gcode(self, event: GcodeEvent):
-        self.logger.info("Gcode: %s", event.list)
-
-        for gcode in event.list:
-            if gcode[:4] == "M104":
-                target = float(gcode[6:])
-
-                self.logger.info(f"Setting tool temperature to {target}")
-                
-                if target > 0.0:
-                    self.virtual_tool_temperatures[0].target = target
-                else:
-                    self.virtual_tool_temperatures[0].target = None
-
-            if gcode[:4] == "M140":
-                target = float(gcode[6:])
-
-                self.logger.info(f"Setting bed temperature to {target}")
-
-                if target > 0.0:
-                    self.virtual_bed_temperature.target = target
-                else:
-                    self.virtual_bed_temperature.target = None
-
-    async def on_start_print(self, _: StartPrintEvent) -> None:
-        self.status = PrinterStatus.PRINTING
-        await asyncio.sleep(10.0)
-        self.print_done() 
-
-    def update(self, dt: float):
-        if self.virtual_bed_temperature.target is None:
-            target = 20.0
-        else:
-            target = self.virtual_bed_temperature.target
-
-        self.virtual_bed_temperature.actual = exponential_smoothing(
-            target, 
-            self.virtual_bed_temperature.actual, 
-            0.05, 
-            dt
-        )
-
-        for tool in self.virtual_tool_temperatures:
-            if tool.target is None:
-                target = 20.0
-            else:
-                target = tool.target
-
-            tool.actual = exponential_smoothing(target, tool.actual, 0.1, dt) 
-    
-    def run(self):
-        self.start()
-
-        while True: 
-            dt: float = 1.0 / self.tick_rate
-
-            time.sleep(dt)
-            self.update(dt)
-
-            self.tool_temperatures = self.virtual_tool_temperatures
-            self.bed_temperature = self.virtual_bed_temperature
-
-if __name__ == "__main__":
-    printer = VirtualPrinter()
-    printer.run()


Test patch:
diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py
new file mode 100644
index 0000000..c5443bc
--- /dev/null
+++ b/tests/test_config_manager.py
@@ -0,0 +1,109 @@
+import unittest
+
+from simplyprint_ws_client.config import Config
+from simplyprint_ws_client.config.json import JsonConfigManager
+from simplyprint_ws_client.config.manager import ConfigManager
+from simplyprint_ws_client.config.memory import MemoryConfigManager
+from simplyprint_ws_client.config.sqlite3 import SqliteConfigManager
+
+class TestConfigManager(unittest.TestCase):
+    def test_internal(self):
+        config_manager = MemoryConfigManager()
+
+        self.assertEqual(config_manager.name, "printers")
+        self.assertEqual(len(config_manager), 0)
+
+        config1 = Config(id=1, token="token1")
+        config2 = Config(id=2, token="token2")
+        config3 = Config(id=3, token="token3")
+
+        self.assertTrue(config1.partial_eq(config1))
+        self.assertTrue(config2.partial_eq(config2))
+        self.assertTrue(config3.partial_eq(config3))
+
+        self.assertFalse(config1.partial_eq(config2))
+        self.assertFalse(config2.partial_eq(config3))
+        self.assertFalse(config3.partial_eq(config1))
+
+        config_manager.persist(config1)
+        config_manager.persist(config2)
+        config_manager.persist(config3)
+
+        self.assertEqual(len(config_manager), 3)
+
+        self.assertEqual(config_manager.by_id(1), config1)
+        self.assertEqual(config_manager.by_id(2), config2)
+        self.assertEqual(config_manager.by_id(3), config3)
+
+        self.assertEqual(config_manager.by_token("token1"), config1)
+        self.assertEqual(config_manager.by_token("token2"), config2)
+        self.assertEqual(config_manager.by_token("token3"), config3)
+
+        self.assertEqual(config_manager.by_other(Config(id=1, token="token1")), config1)
+        self.assertEqual(config_manager.by_other(Config(id=2, token="token2")), config2)
+        self.assertEqual(config_manager.by_other(Config(id=3, token="token3")), config3)
+        self.assertNotEqual(config_manager.by_other(Config(id=3, token="token4")), config3)
+
+    def _test_manager(self, config_manager: ConfigManager):
+        config = Config.get_blank()
+        config_manager.persist(config)
+
+        self.assertEqual(len(config_manager), 1)
+        self.assertEqual(config_manager.by_id(config.id), config)
+
+        config_manager.flush()
+
+        config_manager.clear()
+        config_manager.load()
+
+        self.assertEqual(len(config_manager), 0)
+
+        config_manager.persist(config)
+        config.id = 1337
+        config.token = "Super cool token"
+        config.unique_id = str(id(config))
+        config.public_ip = "127.0.0.1"
+
+        config_manager.flush(config)
+
+        config_manager.clear()
+        config_manager.load()
+
+        self.assertEqual(len(config_manager), 1)
+
+        config2 = config_manager.by_id(config.id)
+
+        self.assertEqual(config2.id, config.id)
+        self.assertEqual(config2.token, config.token)
+        self.assertEqual(config2.unique_id, config.unique_id)
+
+        config_manager.remove(config2)
+
+        self.assertEqual(len(config_manager), 0)
+        config_manager.flush()
+        config_manager.load()
+        self.assertEqual(len(config_manager), 0)
+
+        # Testing pending printer type scenario
+        config = Config.get_blank()
+        config_manager.persist(config)
+        config_manager.flush()
+        
+        config.id = 1336
+        config.token = "Super cool token"
+
+        config_manager.flush(config)
+        config_manager.clear()
+        config_manager.load()
+        
+        self.assertEqual(len(config_manager), 1)
+
+        config_manager.deleteStorage()
+
+    def test_json_manager(self):
+        self._test_manager(JsonConfigManager())
+        self.assertFalse(JsonConfigManager()._json_file.exists())
+
+    def test_sqlite3_manager(self):
+        self._test_manager(SqliteConfigManager())      
+        self.assertFalse(SqliteConfigManager()._database_file.exists())  
\ No newline at end of file
diff --git a/tests/test_event_bus.py b/tests/test_event_bus.py
new file mode 100644
index 0000000..dca8d03
--- /dev/null
+++ b/tests/test_event_bus.py
@@ -0,0 +1,62 @@
+import asyncio
+import unittest
+
+from simplyprint_ws_client.events.event import Event
+from simplyprint_ws_client.events.event_bus import EventBus
+
+class CustomEvent(Event):
+    def __init__(self, data = None) -> None:
+        self.data = data
+
+    @classmethod
+    def get_name(cls) -> str:
+        return "custom"
+
+class CustomEventBus(EventBus[CustomEvent]):
+    ...
+
+class TestEventBus(unittest.IsolatedAsyncioTestCase):
+    event_bus: CustomEventBus
+    called_test = 0
+    called_other = 0
+    called_custom = 0
+    called_always = 0
+
+    def setUp(self) -> None:
+        self.event_bus = CustomEventBus()
+        self.event_bus.on("test", self.on_test)
+        self.event_bus.on("other", self.on_other)
+        self.event_bus.on(CustomEvent, self.on_custom)
+        self.event_bus.on_generic(CustomEvent, self.on_always)
+
+    async def on_test(self, event: CustomEvent):
+        self.called_test += 1
+
+    def on_other(self, event: CustomEvent):
+        self.called_other += 1
+
+    def on_custom(self, event: CustomEvent):
+        self.called_custom += 1
+
+    def on_always(self, event: CustomEvent):
+        self.called_always += 1
+
+    async def test_event_bus(self):
+        await self.event_bus.emit("test", CustomEvent())
+        self.assertEqual(self.called_test, 1)
+
+        await self.event_bus.emit("other", CustomEvent())
+        self.assertEqual(self.called_other, 1)
+
+        await self.event_bus.emit("other", CustomEvent())
+        await self.event_bus.emit("other", CustomEvent())
+
+        self.assertEqual(self.called_other, 3)
+
+        await self.event_bus.emit(CustomEvent())
+        self.assertEqual(self.called_custom, 1)
+        await self.event_bus.emit(CustomEvent())
+        await self.event_bus.emit(CustomEvent())
+
+        self.assertEqual(self.called_always, 3)
+        
\ No newline at end of file
diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py
new file mode 100644
index 0000000..ff8950b
--- /dev/null
+++ b/tests/test_file_manager.py
@@ -0,0 +1,77 @@
+import time
+import unittest
+
+from simplyprint_ws_client.helpers.file_manager import File, FileManager
+
+test_time = time.time()
+
+def test_files(): return [
+    File("test1.gcode", 100, last_modified=test_time - 1000),
+    File("test2.gcode", 200, last_modified=test_time - 2000),
+    File("test3.gcode", 300, last_modified=test_time - 3000),
+    File("test4.gcode", 400, last_modified=test_time - 4000),
+    File("test5.gcode", 500, last_modified=test_time - 5000),
+]
+
+
+class TestFileManager(unittest.TestCase):
+    def test_file_manager(self):
+        fm = FileManager(max_age=1001, max_count=2,
+                         max_size=250, least_remaining_space_percentage=0.1)
+        files_to_remove = list(
+            fm.get_files_to_remove(test_files(), 2000, 1500))
+        
+
+        self.assertEqual(files_to_remove, [
+            File("test2.gcode", 200, last_modified=test_time - 2000),
+            File("test3.gcode", 300, last_modified=test_time - 3000),
+            File("test4.gcode", 400, last_modified=test_time - 4000),
+            File("test5.gcode", 500, last_modified=test_time - 5000),
+        ])
+
+    def test_file_manager_max_age(self):
+        fm = FileManager(max_age=1001, max_count=0,
+                         max_size=0, least_remaining_space_percentage=0)
+        files_to_remove = list(
+            fm.get_files_to_remove(test_files(), 2000, 1500))
+        self.assertEqual(files_to_remove, [
+            File("test2.gcode", 200, last_modified=test_time - 2000),
+            File("test3.gcode", 300, last_modified=test_time - 3000),
+            File("test4.gcode", 400, last_modified=test_time - 4000),
+            File("test5.gcode", 500, last_modified=test_time - 5000),
+        ])
+
+    def test_file_manager_max_count(self):
+        fm = FileManager(max_age=0, max_count=2, max_size=0,
+                         least_remaining_space_percentage=0)
+        files_to_remove = list(
+            fm.get_files_to_remove(test_files(), 2000, 1500))
+        
+        self.assertEqual(files_to_remove, [
+            File("test5.gcode", 500, last_modified=test_time - 5000),
+            File("test4.gcode", 400, last_modified=test_time - 4000),
+            File("test3.gcode", 300, last_modified=test_time - 3000),
+        ])
+
+    def test_file_manager_max_size(self):
+        fm = FileManager(max_age=0, max_count=0, max_size=250,
+                         least_remaining_space_percentage=0)
+        files_to_remove = list(
+            fm.get_files_to_remove(test_files(), 2000, 1500))
+
+        self.assertEqual(files_to_remove, [
+            File("test3.gcode", 300, last_modified=test_time - 3000),
+            File("test4.gcode", 400, last_modified=test_time - 4000),
+            File("test5.gcode", 500, last_modified=test_time - 5000),
+        ])
+
+    def test_file_manager_least_remaining_space_percentage(self):
+        fm = FileManager(max_age=0, max_count=0, max_size=0,
+                         least_remaining_space_percentage=1)
+        files_to_remove = list(
+            fm.get_files_to_remove(test_files(), 2000, 1500))
+        self.assertEqual(files_to_remove, list(reversed(test_files())))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_job_info_state.py b/tests/test_job_info_state.py
new file mode 100644
index 0000000..b8e4014
--- /dev/null
+++ b/tests/test_job_info_state.py
@@ -0,0 +1,98 @@
+import unittest
+
+from traitlets import Enum, Instance
+from simplyprint_ws_client.events.client_events import JobInfoEvent, StateChangeEvent
+from simplyprint_ws_client.state.printer import JobInfoState, PrinterStatus
+from simplyprint_ws_client.state import to_event
+
+from simplyprint_ws_client.state.root_state import RootState
+
+@to_event(StateChangeEvent, "status")
+class TestState(RootState):
+    status: PrinterStatus = Enum(PrinterStatus)
+    job_info: JobInfoState = Instance(JobInfoState)
+
+    def __init__(self, **kwargs):
+        super().__init__(
+            status=PrinterStatus.OFFLINE,
+            job_info=JobInfoState(),
+        )
+
+class TestJobInfoState(unittest.TestCase):
+    def test_basic_state(self):
+        state = TestState()
+        
+        self.assertEqual(state.get_event_types(), [
+            StateChangeEvent,
+        ])
+
+        self.assertEqual(state.status, PrinterStatus.OFFLINE)
+    
+    def test_state_resetting(self):
+        state = TestState()
+        state.status = PrinterStatus.OPERATIONAL
+        state.job_info.started = True
+
+        # Consume all initial events
+        list(state._build_events())
+        self.assertEqual(state.get_event_types(), [])
+
+        state.job_info.started = True
+
+        self.assertEqual(state.get_event_types(), [ JobInfoEvent ])
+
+        # Consume all events
+        list(state._build_events())
+        self.assertEqual(state.get_event_types(), [])
+        state.job_info.started = True
+        self.assertEqual(state.get_event_types(), [ JobInfoEvent ])
+
+    def test_status_change(self):
+        state = TestState()
+        state.status = PrinterStatus.PRINTING
+
+        self.assertEqual(state.get_event_types(), [
+            StateChangeEvent,
+        ])
+        
+        self.assertEqual(state.status, PrinterStatus.PRINTING)
+
+    def test_combined_ordering(self):
+        state = TestState()
+        state.status = PrinterStatus.PRINTING
+        state.job_info.started = True
+
+        # Ensure the order of the events is correct
+        self.assertEqual(state.get_event_types(), [
+            StateChangeEvent,
+            JobInfoEvent,
+        ])
+        
+        self.assertEqual(state.status, PrinterStatus.PRINTING)
+
+    def test_single_bool_output(self):
+        state = TestState()
+
+        # Class identifier being tracked
+        identifier = id(state.job_info)
+
+        # Consume all initial events
+        _ = list(state._build_events())
+
+        state.job_info.started = True
+        
+        # Retrieve changeset
+        self.assertEqual(state._changed_fields[identifier], set(["started"]))
+
+        state.job_info.finished = True
+
+        self.assertEqual(state._changed_fields[identifier], set(["started", "finished"]))
+
+        # Consume event
+        event = next(state._build_events())
+
+        self.assertEqual(event.__class__, JobInfoEvent)
+
+        event_data = dict(event.generate_data())
+
+        self.assertEqual(event_data.keys(), set(["finished"]))
diff --git a/tests/test_material_data.py b/tests/test_material_data.py
new file mode 100644
index 0000000..fbf0bed
--- /dev/null
+++ b/tests/test_material_data.py
@@ -0,0 +1,81 @@
+from typing import List, Optional
+import unittest
+
+from traitlets import Instance, Integer, List as TraitletsList
+from simplyprint_ws_client.events.client_events import MaterialDataEvent, ToolEvent
+from simplyprint_ws_client.state import RootState, to_event
+from simplyprint_ws_client.state.models import MaterialModel
+
+@to_event(MaterialDataEvent, "material_data")
+@to_event(ToolEvent, "active_tool")
+class TestState(RootState):
+    active_tool: Optional[int] = Integer(None, allow_none=True)
+    material_data: List[MaterialModel] = TraitletsList(Instance(MaterialModel))
+
+    def __init__(self, **kwargs):
+        super().__init__(
+            active_tool=None,
+            material_data=[],
+        )
+
+    def get_events(self):
+        events = list(self._build_events())
+        return list(map(lambda x: x.__class__, events)), events
+
+class TestJobInfoState(unittest.TestCase):
+    def test_basic_state(self):
+        state = TestState()
+
+        self.assertEqual(state.active_tool, None)
+        self.assertEqual(state.material_data, [])
+
+        state.active_tool = 0
+        state.material_data.append(MaterialModel(type="PLA", color="red", hex="#FF0000", ext=0))
+
+        event_types, events = state.get_events()
+        self.assertEqual(event_types, [MaterialDataEvent, ToolEvent])
+        event: MaterialDataEvent = events[0]
+        self.assertDictEqual(dict(event.generate_data()), {'materials': [{'color': 'red', 'ext': 0, 'hex': '#FF0000', 'type': 'PLA'}]})
+
+        event_types, events = state.get_events()
+        self.assertEqual(event_types, [])
+        self.assertEqual(events, [])
+
+        state.active_tool = 1
+
+        event_types, events = state.get_events()
+        self.assertEqual(event_types, [ToolEvent])
+        event: ToolEvent = events[0]
+
+        self.assertDictEqual(dict(event.generate_data()), {'new': 1})
+
+        # Add 3 more materials
+        state.material_data = [
+            MaterialModel(type="PLA", color="red", hex="#FF0000", ext=0),
+            MaterialModel(type="PLA", color="green", hex="#00FF00", ext=1),
+            MaterialModel(type="PLA", color="blue", hex="#0000FF", ext=2),
+            MaterialModel(type="PLA", color="white", hex="#FFFFFF", ext=3)
+        ]
+
+        event_types, events = state.get_events()
+        self.assertEqual(event_types, [MaterialDataEvent])
+        event: MaterialDataEvent = events[0]
+
+        self.assertDictEqual(dict(event.generate_data()), {'materials': [{'color': 'red', 'ext': 0, 'hex': '#FF0000', 'type': 'PLA'}, {'color': 'green', 'ext': 1, 'hex': '#00FF00', 'type': 'PLA'}, {'color': 'blue', 'ext': 2, 'hex': '#0000FF', 'type': 'PLA'}, {'color': 'white', 'ext': 3, 'hex': '#FFFFFF', 'type': 'PLA'}]})
+
+        # Change both
+        state.material_data = [
+            MaterialModel(type="PLA", color="red", hex="#FF0000", ext=0),
+            MaterialModel(type="PLA", color="green", hex="#00FF00", ext=1),
+            MaterialModel(type="PLA", color="blue", hex="#0000FF", ext=2),
+            MaterialModel(type="PLA", color="white", hex="#FFFFFF", ext=3)
+        ]
+        state.active_tool = 0
+
+        event_types, events = state.get_events()
+        self.assertEqual(event_types, [MaterialDataEvent, ToolEvent])
+        event_material: MaterialDataEvent = events[0]
+        event_tool: ToolEvent = events[1]
+
+        self.assertDictEqual(dict(event_material.generate_data()), {'materials': [{'color': 'red', 'ext': 0, 'hex': '#FF0000', 'type': 'PLA'}, {'color': 'green', 'ext': 1, 'hex': '#00FF00', 'type': 'PLA'}, {'color': 'blue', 'ext': 2, 'hex': '#0000FF', 'type': 'PLA'}, {'color': 'white', 'ext': 3, 'hex': '#FFFFFF', 'type': 'PLA'}]})
+        self.assertDictEqual(dict(event_tool.generate_data()), {'new': 0})
\ No newline at end of file