diff --git a/programs/ae33.py b/programs/ae33.py
new file mode 100644
index 0000000000000000000000000000000000000000..1477b522596451ae4016674696884abc6ac4c83b
--- /dev/null
+++ b/programs/ae33.py
@@ -0,0 +1,55 @@
+import sys
+
+from umnp.microcontroller.communication.udp_communicator import UDPCommunicator
+from umnp.microcontroller.devices.network.ethernet_w5500 import EthernetW5500
+from umnp.microcontroller.devices.network.udp import UDPSender, UDPReceiver
+from umnp.microcontroller.measurementdevice import MeasurementDevice
+from umnp.microcontroller.tasks.periodictask import PeriodicTask
+from umnp.protocol.common import UDP_DATA_PORT, UDP_CMD_PORT
+
+if sys.implementation.name == "micropython":
+    # noinspection PyUnresolvedReferences
+    import machine
+
+    # noinspection PyUnresolvedReferences
+    import ntptime
+
+    # noinspection PyUnresolvedReferences
+    import uasyncio as asyncio
+else:
+    from umnp.microcontroller.umock import machine
+    import asyncio
+
+
+def report_time(*args) -> str:
+    return ""
+
+
+def main():
+    # configure network
+    device = MeasurementDevice()
+    spi = machine.SPI(
+        0, 2_000_000, mosi=machine.Pin(19), miso=machine.Pin(16), sck=machine.Pin(18)
+    )
+    ether = EthernetW5500(
+        spi, machine.Pin(17), machine.Pin(20), mac=device.generated_mac_raw(), dhcp=True
+    )
+
+    uart = machine.UART(1, 11)
+
+    device.add_network_adapter(ether)
+    sender = UDPSender(ether.ip, ether.netmask, UDP_DATA_PORT)
+    receiver = UDPReceiver(ether.ip, UDP_CMD_PORT)
+
+    communicator = UDPCommunicator(
+        receiver=receiver, sender=sender, device_id=device.identifier
+    )
+
+    timer = PeriodicTask(report_time, print, 1000)
+    communicator.add_task(task=timer, name="timer")
+    # start
+    asyncio.run(communicator.start())
+
+
+if __name__ == "__main__":
+    main()
diff --git a/programs/base_program.py b/programs/base_program.py
new file mode 100644
index 0000000000000000000000000000000000000000..47002bcc650d128620a7592a3f7e6709914f7913
--- /dev/null
+++ b/programs/base_program.py
@@ -0,0 +1,55 @@
+import sys
+
+from umnp.microcontroller.communication.udp_communicator import UDPCommunicator
+from umnp.microcontroller.devices.network.ethernet_w5500 import EthernetW5500
+from umnp.microcontroller.devices.network.udp import UDPSender, UDPReceiver
+from umnp.microcontroller.measurementdevice import MeasurementDevice
+from umnp.microcontroller.tasks.periodictask import PeriodicTask
+from umnp.protocol.common import UDP_DATA_PORT, UDP_CMD_PORT
+
+if sys.implementation.name == "micropython":
+    # noinspection PyUnresolvedReferences
+    import machine
+
+    # noinspection PyUnresolvedReferences
+    import ntptime
+
+    # noinspection PyUnresolvedReferences
+    import uasyncio as asyncio
+else:
+    from umnp.microcontroller.umock import machine
+    import asyncio
+
+
+def report_time(*args) -> str:
+    return ""
+
+
+def main():
+    # configure network
+    device = MeasurementDevice()
+    spi = machine.SPI(
+        0, 2_000_000, mosi=machine.Pin(19), miso=machine.Pin(16), sck=machine.Pin(18)
+    )
+    ether = EthernetW5500(
+        spi, machine.Pin(17), machine.Pin(20), mac=device.generated_mac_raw(), dhcp=True
+    )
+
+    ntptime.settime()
+
+    device.add_network_adapter(ether)
+    sender = UDPSender(ether.ip, ether.netmask, UDP_DATA_PORT)
+    receiver = UDPReceiver(ether.ip, UDP_CMD_PORT)
+
+    communicator = UDPCommunicator(
+        receiver=receiver, sender=sender, device_id=device.identifier
+    )
+
+    timer = PeriodicTask(report_time, print, 1000)
+    communicator.add_task(task=timer, name="timer")
+    # start
+    asyncio.run(communicator.start())
+
+
+if __name__ == "__main__":
+    main()
diff --git a/programs/sensor_calibration.py b/programs/sensor_calibration.py
index 9fcbb0e0bbb8a655b09aa50a8d5b44659418b9cc..27b43b661e5e289e82394e37718f0803b883bf91 100644
--- a/programs/sensor_calibration.py
+++ b/programs/sensor_calibration.py
@@ -32,7 +32,10 @@ def main():
     spi = machine.SPI(
         0, 2_000_000, mosi=machine.Pin(19), miso=machine.Pin(16), sck=machine.Pin(18)
     )
-    ether = EthernetW5500(spi, 17, 20, mac=device.generated_mac_raw(), dhcp=True)
+    ether = EthernetW5500(
+        spi, machine.Pin(17), machine.Pin(20), mac=device.generated_mac_raw(), dhcp=True
+    )
+    print(ether.mac)
     device.add_network_adapter(ether)
 
     i2c = machine.I2C(id=1, scl=machine.Pin(27), sda=machine.Pin(26))
@@ -49,3 +52,7 @@ def main():
     comm.add_task(x, "test_function")
     # start
     asyncio.run(comm.start())
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/ae33-info.py b/scripts/ae33-info.py
new file mode 100644
index 0000000000000000000000000000000000000000..80d82a6902a420ead8fa2f252b0bac4fdef73f02
--- /dev/null
+++ b/scripts/ae33-info.py
@@ -0,0 +1,39 @@
+import time
+
+import serial
+
+conn = serial.Serial(
+    port="/dev/ttyUSB0",
+    baudrate=115200,
+    dsrdtr=True,
+    xonxoff=False,
+    rtscts=False,
+    timeout=0.5,
+)
+
+
+print("Connected")
+time.sleep(1)
+print()
+print("get newest log")
+conn.write(b"$AE33:L1\r")
+print(conn.readline())
+
+time.sleep(1)
+print()
+print("get tape advances")
+conn.write(b"$AE33:A\r")
+print(conn.readlines())
+
+time.sleep(1)
+print()
+print("get setup file")
+conn.write(b"$AE33:SG\r")
+print(conn.readlines())
+print()
+
+time.sleep(1)
+print("start measurement")
+conn.write(b"$AE33:X1\r")
+print(conn.readlines())
+print()
diff --git a/scripts/ae33-measure.py b/scripts/ae33-measure.py
new file mode 100644
index 0000000000000000000000000000000000000000..c11ed74333839f6284819e9c3516cb81cfcf2da3
--- /dev/null
+++ b/scripts/ae33-measure.py
@@ -0,0 +1,21 @@
+import time
+
+import serial
+
+conn = serial.Serial(
+    port="/dev/ttyUSB0",
+    baudrate=115200,
+    dsrdtr=True,
+    xonxoff=False,
+    rtscts=False,
+    timeout=0.5,
+)
+
+
+print("Connected")
+
+
+for i in range(5):
+    conn.write(b"$AE33:D1\r")
+    print(conn.readline())
+    time.sleep(0.2)
diff --git a/scripts/helpers/__init__.py b/scripts/helpers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/scripts/helpers/ampy.py b/scripts/helpers/ampy.py
new file mode 100644
index 0000000000000000000000000000000000000000..c0a59f9ecb803e0d43a9c8c46dfd9cc7c78c9d0d
--- /dev/null
+++ b/scripts/helpers/ampy.py
@@ -0,0 +1,42 @@
+import datetime
+import os
+import subprocess
+
+
+def upload_file(port: str, local_path: str, remote_path: str | None = None):
+    if remote_path is None:
+        remote_path = local_path
+    if not os.path.exists(local_path):
+        raise ValueError("fFile {fn} does not exist")
+    if remote_path != local_path:
+        print(f"Uploading file {local_path} as {remote_path}")
+    else:
+        print(f"Uploading file {local_path}")
+    cmd = ["ampy", "--port", port, "put", local_path, remote_path]
+    subprocess.run(cmd)
+
+
+def list_remote_file_dates_and_directories(port):
+    cmd = ["ampy", "--port", port, "run", "tools/list-files.py"]
+    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+    files = {}
+    dirs = []
+    for line in proc.stdout:
+        line = line.decode("utf-8")
+        fn, size, _, f_type, date = line.split()
+        while fn.startswith("/"):
+            fn = fn[1:]
+        if f_type == "f":
+            files[fn] = datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S")
+        elif f_type == "d":
+            dirs.append(fn)
+
+    return files, dirs
+
+
+def make_remote_directory(path: str, port: str):
+    if not path:
+        return
+    print(f"Creating directory {path}")
+    cmd = ["ampy", "--port", port, "mkdir", path]
+    subprocess.run(cmd)
diff --git a/scripts/helpers/filesystem.py b/scripts/helpers/filesystem.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8b616e3d5017c47012439cc0c7d349f27b8881f
--- /dev/null
+++ b/scripts/helpers/filesystem.py
@@ -0,0 +1,13 @@
+import os
+
+
+def get_directory_tree_from_directories(directories: list[str]) -> list[str]:
+    dirs = list(set([os.path.split(d)[0] for d in directories]))
+    all_dirs = dirs
+    for d in dirs:
+        while d:
+            d = os.path.split(d)[0]
+            if d in all_dirs:
+                continue
+            all_dirs.append(d)
+    return list(sorted(list(set(dirs))))
diff --git a/scripts/helpers/git.py b/scripts/helpers/git.py
new file mode 100644
index 0000000000000000000000000000000000000000..19d81dedc57e406f7f4e632cea30d26899d07198
--- /dev/null
+++ b/scripts/helpers/git.py
@@ -0,0 +1,58 @@
+import subprocess
+
+
+def parse_git_status(state: str) -> str:
+    if len(state) == 0 or len(state) > 2:
+        raise ValueError
+    if "M" in state:
+        return "modified"
+    if "??" in state:
+        return "untracked"
+    if "D" in state:
+        return "deleted"
+    if "A" in state:
+        return "added"
+    if "T" in state:
+        return "type changed"
+    if "R" in state:
+        return "renamed"
+    if "C" in state:
+        return "copied"
+
+
+def get_git_unclean_files() -> dict[str:str]:
+    """
+    Returns a dictionary of unclean files of the git repository, in which the current directory resides.
+
+    Raises
+    ------
+    ValueError
+        If 'git status' can't be executed or fails with an error
+
+    Returns
+    -------
+    dict[str: str]
+        Keys of this dictionary are the paths of 'dirty' files (e.g. uncommitted and untracked files),
+        and a description as value.
+    """
+
+    git_status = {}
+    cmd = ["git", "status", "--porcelain=v1"]
+    try:
+        proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        proc.wait()
+    except FileNotFoundError:
+        raise ValueError("git executable not found")
+
+    if proc.returncode != 0:
+        error = "\n".join([x.decode("utf-8") for x in proc.stderr])
+        raise ValueError(f"Error running {' '.join(cmd)}: {error}")
+
+    for line in proc.stdout:
+        fields = line.decode("utf-8").split()
+        status = fields[0]
+        file = " ".join(fields[1:])
+        if file.startswith('"') and file.endswith('"'):
+            file = file[1:-1]
+        git_status[file] = parse_git_status(status)
+    return git_status
diff --git a/scripts/helpers/imports.py b/scripts/helpers/imports.py
new file mode 100644
index 0000000000000000000000000000000000000000..ac949d2a8f3a7743afb68e516004774aaa92138b
--- /dev/null
+++ b/scripts/helpers/imports.py
@@ -0,0 +1,55 @@
+import ast
+import os
+
+
+def get_module_names(node: ast.Import):
+    names = [n.name for n in node.names]
+    return names
+
+
+def get_imported_files(
+    fn,
+    modules=None,
+    files=None,
+    filter_by: str | None = None,
+    ignore: str | None = None,
+):
+    if modules is None:
+        modules = []
+    if files is None:
+        files = []
+
+    current_modules = []
+
+    with open(fn) as f:
+        root = ast.parse(f.read(), fn)
+        for node in ast.iter_child_nodes(root):
+            if isinstance(node, ast.Import):
+                current_modules.extend(get_module_names(node))
+            elif isinstance(node, ast.ImportFrom):
+                current_modules.append(node.module)
+
+    current_modules = sorted(list(set(current_modules)))
+    for module in current_modules:
+        if module in modules:
+            pass
+
+        module_fn = module.replace(".", os.sep)
+
+        if (filter_by and module_fn.startswith(filter_by)) or filter_by is None:
+            if ignore is not None and ignore in module_fn:
+                continue
+
+            if os.path.exists(module_fn) and os.path.isdir(module_fn):
+                module_fn = os.path.join(module_fn, "__init__.py")
+            else:
+                module_fn = module_fn + ".py"
+            modules.append(module)
+            files.append(module_fn)
+            files, modules = get_imported_files(
+                module_fn, modules, files, filter_by=filter_by, ignore=ignore
+            )
+
+    files = list(sorted(list(set(files))))
+    modules = list(sorted(list(set(modules))))
+    return files, modules
diff --git a/scripts/upload-script.py b/scripts/upload-script.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6107cf2d560d1e3e0c13581c7c0a575d2d1041c
--- /dev/null
+++ b/scripts/upload-script.py
@@ -0,0 +1,136 @@
+#! /usr/bin/env/python3
+
+"""
+upload-script.py
+
+Uploads a Python script, including imported dependencies to a microcontroller using ampy.
+Unless otherwise specified, the upload will only occur, if the files do not contain any changes not yet committed
+to git, i.e. the working tree (or the parts of it that will be uploaded) are not dirty.
+If a file to be uploaded already exists on the microcontroller, it will only be uploaded if newer.
+
+Example:
+python ./upload-script.py programs/sensor_calibration.py  --allow-dirty --port "/dev/ttyACM0"
+
+"""
+import argparse
+import datetime
+import os
+import sys
+
+# this is ugly and probably not recommended, but enables imports relative to this file
+sys.path.append(os.path.dirname(os.path.dirname(__file__)))
+
+from helpers.ampy import (
+    upload_file,
+    list_remote_file_dates_and_directories,
+    make_remote_directory,
+)
+from helpers.filesystem import get_directory_tree_from_directories
+from helpers.git import get_git_unclean_files
+from helpers.imports import get_imported_files
+
+
+def remote_file_older_or_missing(path: str, remote_file_date: datetime.datetime | None):
+    if not remote_file_date:
+        return True
+
+    disk_date = datetime.datetime.fromtimestamp(os.path.getmtime(path))
+    if disk_date > remote_file_date:
+        return True
+
+    return False
+
+
+def make_directories(port: str, local_dirs: list[str], remote_dirs: list[str]):
+    for path in local_dirs:
+        if path in remote_dirs:
+            print(f"Skipping creation of '{path}': directory already exists")
+            continue
+        make_remote_directory(path, port=port)
+
+
+def upload_files(local_files, script, port: str, ignore_file_times=False):
+    remote_file_dates, remote_directories = list_remote_file_dates_and_directories(port)
+    local_directories = get_directory_tree_from_directories(local_files)
+    make_directories(port, local_directories, remote_directories)
+
+    for file_path in local_files:
+        remote_date = remote_file_dates.get(file_path)
+        local_newer = remote_file_older_or_missing(file_path, remote_date)
+        if ignore_file_times or local_newer:
+            upload_file(port=port, local_path=file_path)
+        else:
+            print(f"Skipping '{file_path}': remote file is newer")
+
+    remote_script_date = remote_file_dates.get("main.py")
+    if remote_file_older_or_missing(script, remote_script_date):
+        upload_file(port, script, "main.py")
+    else:
+        print(f"Skipping upload '{script}' as main.py: remote file is newer")
+
+
+def check_for_unclean_files(files_to_upload: list[str], args):
+    if not args.no_git:
+        return
+    errors = 0
+    unclean_files = get_git_unclean_files()
+
+    for fn in files_to_upload:
+        if fn in unclean_files:
+            state = unclean_files[fn]
+            print(f"Warning: file '{fn}': {state}")
+            errors += 1
+
+    if errors == 0:
+        return
+
+    print("Warning: files to upload contain uncommitted changes.")
+    if args.allow_dirty:
+        print("Continuing with dirty working tree")
+        return
+
+    print("Please either commit and retry, or re-run this script with --dirty")
+    exit(1)
+
+
+def main(args):
+    fn = args.script
+    files, modules = get_imported_files(fn, filter_by="umnp", ignore="mock")
+    check_for_unclean_files(files, args)
+    upload_files(files, fn, port=args.port, ignore_file_times=args.ignore_time)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(prog=sys.argv[0])
+    parser.add_argument(
+        "--allow-dirty",
+        action="store_true",
+        help="allow upload even when changes to uploaded files are not yet committed to git",
+    )
+    parser.add_argument(
+        "--ignore-time",
+        action="store_true",
+        help="ignore file timestamps and always upload, even when the "
+        "files on the disk are older than on the device",
+    )
+    parser.add_argument(
+        "--no-git", action="store_true", help="don't check for git status"
+    )
+    parser.add_argument(
+        "--port", help="address or port of the attached micro-controller", required=True
+    )
+
+    parser.add_argument(
+        "script",
+        help="Name of the python script file to upload. Will be renamed to main.py",
+    )
+
+    args_ = parser.parse_args()
+    if not os.path.exists(args_.script):
+        print(f"File '{args_.script}' does not exist")
+        exit(1)
+    if not os.path.exists(args_.port):
+        print(f"Can't connect to '{args_.port}': no such file")
+        exit(1)
+
+    main(args_)
diff --git a/tools/backup.py b/tools/backup.py
deleted file mode 100644
index b30ff8f04a955b51b90c3be08a65166e33eebe77..0000000000000000000000000000000000000000
--- a/tools/backup.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from fs import FileSystemInformation
-
-current_dir = ""
-
-
-def main():
-    fs = FileSystemInformation()
-    fs.print_fs_tree()
-
-
-main()
diff --git a/umnp/aux/__init__.py b/umnp/aux/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7110bc0863601240e5f94501c3a3c3afdc7c7658
--- /dev/null
+++ b/umnp/aux/__init__.py
@@ -0,0 +1,9 @@
+import datetime
+
+
+def utc_now_rounded_second() -> datetime.datetime:
+    now = datetime.datetime.now(tz=datetime.timezone.utc)
+    if now.microsecond >= 500 * 1000:
+        now = now + datetime.timedelta(seconds=1)
+    now = now.replace(microsecond=0)
+    return now
diff --git a/umnp/aux/log_helper.py b/umnp/aux/log_helper.py
new file mode 100644
index 0000000000000000000000000000000000000000..eda563d56e7305c4c0e3b8da1e55f3517cd84507
--- /dev/null
+++ b/umnp/aux/log_helper.py
@@ -0,0 +1,24 @@
+import logging
+import os
+
+
+def setup_logger(name: str, log_dir: str | None = None):
+    now = utc_now_rounded_second()
+    now_string = now.isoformat().replace(":", "-")
+    now_string = now_string.split("+")[0]
+
+    logger = logging.getLogger()
+    logger.setLevel(logging.DEBUG)
+
+    if log_dir:
+        if not os.path.exists(log_dir):
+            os.mkdir(log_dir)
+        log_file_fn = os.path.join(log_dir, f"{name}-{now_string}.log")
+        log_file = logging.FileHandler(log_file_fn)
+        log_file.setLevel(logging.DEBUG)
+        logger.addHandler(log_file)
+
+    console_logger = logging.StreamHandler()
+    logger.addHandler(console_logger)
+
+    return logger
diff --git a/umnp/devices/aethalometer/ae33.py b/umnp/devices/aethalometer/ae33.py
index 60f337ec1aeb1e7698fcc35154b17fd57f62ecb3..21eb348f54a92e6b7106d829f0015e5d57feba74 100644
--- a/umnp/devices/aethalometer/ae33.py
+++ b/umnp/devices/aethalometer/ae33.py
@@ -1,5 +1,7 @@
 from umnp.communication.abstract_serial_connection import AbstractSerialConnection
 
+AE33_BAUDRATE = 115200
+
 
 class SerialConnection:
     def __init__(self):
diff --git a/umnp/microcontroller/communication/serial_uart.py b/umnp/microcontroller/communication/serial_uart.py
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d943879eee3f3914f8ef7eb7cd33adbfbd0aa161 100644
--- a/umnp/microcontroller/communication/serial_uart.py
+++ b/umnp/microcontroller/communication/serial_uart.py
@@ -0,0 +1,13 @@
+import sys
+
+from umnp.communication.abstract_serial_connection import AbstractSerialConnection
+
+if sys.implementation == "micropython":
+    import machine
+else:
+    from umnp.microcontroller.umock import machine
+
+
+class UARTSerial(AbstractSerialConnection):
+    def __init__(self, address: machine.UART):
+        super().__init__(address)
diff --git a/umnp/microcontroller/devices/network/ethernet_w5500.py b/umnp/microcontroller/devices/network/ethernet_w5500.py
index 43425a25d810b0c81a79f16ddb6d4b11c3f1a003..bc54763f65a7a0a37f87abb8d852c88ba275baaf 100644
--- a/umnp/microcontroller/devices/network/ethernet_w5500.py
+++ b/umnp/microcontroller/devices/network/ethernet_w5500.py
@@ -13,18 +13,25 @@ else:
 
 
 class EthernetW5500(EthernetAdapter):
-    def __init__(self, spi: machine.SPI, pin1: int, pin2: int, mac: bytes, dhcp=False):
+    def __init__(
+        self,
+        spi: machine.SPI,
+        pin1: machine.Pin,
+        pin2: machine.Pin,
+        mac: bytes,
+        dhcp=False,
+    ):
         super().__init__()
         self._spi = spi
-        self._pin1 = machine.Pin(pin1)
-        self._pin2 = machine.Pin(pin2)
-        self._nic = network.WIZNET5K(spi, self._pin1, self._pin2, mac)
-        # self._nic.active(True)
+        self._pin1 = pin1
+        self._pin2 = pin2
+        self._nic = network.WIZNET5K(spi, self._pin1, self._pin2)
+        self._nic.active(True)
         self._nic.config(mac=mac)
         if dhcp:
             self.enable_dhcp()
 
-    def get_dhcp(self):
+    def enable_dhcp(self):
         while True:
             print("Requesting IP via DHCP")
             try:
@@ -32,10 +39,7 @@ class EthernetW5500(EthernetAdapter):
                 return
             except OSError:
                 pass
-
-    def enable_dhcp(self):
-        self.get_dhcp()
-        print(self._nic.ifconfig())
+            print(self._nic.ifconfig())
 
     @property
     def netmask(self):
@@ -44,3 +48,7 @@ class EthernetW5500(EthernetAdapter):
     @property
     def ip(self):
         return self._nic.ifconfig()[0]
+
+    @property
+    def mac(self):
+        return self._nic.config("mac")
diff --git a/umnp/microcontroller/umock/machine/__init__.py b/umnp/microcontroller/umock/machine/__init__.py
index 5bda3828b3e4f6bf7f12d9d65e8a8eec73bf73a4..4f6fadb0f3c4c452681f6893f086d0027eed20bb 100644
--- a/umnp/microcontroller/umock/machine/__init__.py
+++ b/umnp/microcontroller/umock/machine/__init__.py
@@ -84,3 +84,42 @@ class I2C:
 
     def writeto_mem(self, i2c_address, address, buffer):
         pass
+
+
+class UART:
+    def __init__(self, id: int): ...
+
+    def init(
+        self,
+        baudrate=9600,
+        bits=8,
+        parity=None,
+        stop=1,
+        tx=None,
+        rx=None,
+        rts=None,
+        cts=None,
+        txbuf=None,
+        rxbuf=None,
+        timeout=None,
+        timeout_chars=None,
+        invert=None,
+        flow=None,
+    ): ...
+
+    def any(self) -> int: ...
+
+    def deinit(self): ...
+
+    def read(self, nbytes=None): ...
+
+    def readinto(self, buf, nbytes=None): ...
+
+    def readline(self): ...
+    def write(self, buf: bytes): ...
+
+    def sendbread(self): ...
+
+    def flush(self): ...
+
+    def txdone(self): ...
diff --git a/umnp/microcontroller/umock/network/__init__.py b/umnp/microcontroller/umock/network/__init__.py
index fe11856f5ea5f325ade7f8d592fb952a79605114..9eb57007e7c11d61f4ed0b88c3542700759bd932 100644
--- a/umnp/microcontroller/umock/network/__init__.py
+++ b/umnp/microcontroller/umock/network/__init__.py
@@ -2,9 +2,9 @@ from umnp.microcontroller.umock.machine import SPI, Pin
 
 
 class WIZNET5K:
-    def __init__(self, spi: SPI, pin1: Pin, pin2: Pin, mac: bytes):
+    def __init__(self, spi: SPI, pin1: Pin, pin2: Pin):
         self._active: bool = False
-        self._mac = b'\xe6ad\x08Cx'  # FIXME
+        self._mac = b"\xe6ad\x08Cx"  # FIXME
         self._ip = "127.0.0.1"
         self._gateway = "127.0.0.2"
         self._netmask = "255.255.255.0"
@@ -12,7 +12,7 @@ class WIZNET5K:
         self._spi = spi
         self._pin1 = pin1
         self._pin2 = pin2
-        self._mac = mac
+        self._mac = None
 
     def active(self, active: bool) -> None:
         self._active = active
@@ -20,8 +20,8 @@ class WIZNET5K:
     def config(self, *args, **kwargs):
         if len(args) == 1 and args[0] == "mac":
             return self._mac
-        if 'mac' in kwargs:
-            self._mac = kwargs['mac']
+        if "mac" in kwargs:
+            self._mac = kwargs["mac"]
 
     def ifconfig(self, *args, **kwargs):
         if len(args) == 1 and args[0] == "dhcp":
diff --git a/umnp/protocol/common/__init__.py b/umnp/protocol/common/__init__.py
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..f7dbdf981942563ede6aa098dcba54b3648bffce 100644
--- a/umnp/protocol/common/__init__.py
+++ b/umnp/protocol/common/__init__.py
@@ -0,0 +1,3 @@
+UDP_DATA_PORT = 7777
+UDP_METADATA_PORT = 7778
+UDP_CMD_PORT = 7776
diff --git a/umnp/protocol/common/timestamp.py b/umnp/protocol/common/timestamp.py
index 575a4e66f6ff8087f0b82d607f7dced7fbdcf473..f9f763546c480c1dec07851edc140a46abc8260e 100644
--- a/umnp/protocol/common/timestamp.py
+++ b/umnp/protocol/common/timestamp.py
@@ -1,13 +1,40 @@
+import sys
+import time
+
 try:
     import datetime
 except ImportError:
     from umnp.protocol.compat.datetime import datetime
 
 
+if sys.implementation == "micropython":
+    # noinspection PyUnresolvedReferences
+    import machine
+
+
+"""
+        rtc = machine.RTC()
+        now = rtc.datetime()
+        year, month, day, hour, minute, second, second_fraction = now
+"""
+
+
+def ts_utc_now_second() -> int:
+    if sys.implementation == "micropython":
+        rtc = machine.RTC()
+        # https://docs.micropython.org/en/latest/library/time.html#module-time
+        # Micropython's time.time() returns the number of seconds, as an integer, since the Epoch
+        return time.time()
+
+    else:
+        ts = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
+        return int(round(ts))
+
+
 class TimeStamp:
     def __init__(self, when=None):
         if when:
-            self._ts = int(round(datetime.datetime.now(datetime.timezone.utc).timestamp()))
+            self._ts = ts_utc_now_second()
         else:
             self._ts = when
 
@@ -29,5 +56,3 @@ def valid_timestamp(when: TimeStamp | None) -> TimeStamp:
         raise ValueError("Expected None TimeStamp")
 
     return when
-
-
diff --git a/umnp/readme.md b/umnp/readme.md
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/umnp/tools/__init__.py b/umnp/tools/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tools/fs/__init__.py b/umnp/tools/fs/__init__.py
similarity index 63%
rename from tools/fs/__init__.py
rename to umnp/tools/fs/__init__.py
index 5fb9dc5512a9b3a2a06b12e2bf52e305182a2635..b1cdf51993f19a305d0c7b6019a3fe43fc781bdb 100644
--- a/tools/fs/__init__.py
+++ b/umnp/tools/fs/__init__.py
@@ -1,30 +1,42 @@
 import os
 
 
-def fs_entries_recurse(current_directory="", total_path=""):
+def human_readable_time(timestamp: int):
+    t = time.localtime(timestamp)
+    year, month, day = t[0:3]
+    hour, minute, second = t[3:6]
+    return f"{year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
+
+
+def fs_entries_recurse(current_directory="", total_path="/"):
     files = []
     dirs = []
     all_entries = []
 
-    for fstats in os.ilistdir(current_directory):
+    for fstats in os.ilistdir(total_path):
         fn = fstats[0]
         ft = fstats[1]
-        all_entries.append(total_path + "/" + fn)
+        if total_path.endswith("/"):
+            target = total_path + fn
+        else:
+            target = total_path + "/" + fn
+        all_entries.append(target)
         if ft == 0x4000:
             dirs.append(total_path + "/" + fn)
 
-            f, current_directory, a = fs_entries_recurse(fn, total_path + "/" + fn)
+            f, current_directory, a = fs_entries_recurse(fn, target)
             files.extend(f)
             dirs.extend(current_directory)
             all_entries.extend(a)
         else:
-            files.append(total_path + "/" + fn)
+            files.append(target)
     return files, dirs, all_entries
 
 
 class FileSystemInformation:
     def __init__(self):
         self._size_cache = {}
+        self._atime_cache = {}
         self._files = []
         self._directories = []
         self._all_entries = []
@@ -38,8 +50,7 @@ class FileSystemInformation:
         self._size_total_kb = None
         self._longest_path = 0
         self.update_file_system_info()
-
-        self._files, self._directories, self._all_entries = fs_entries_recurse()
+        self._files, self._directories, self._all_entries = fs_entries_recurse("")
         for fn in self._all_entries:
             self._longest_path = max(self._longest_path, len(fn))
 
@@ -50,17 +61,22 @@ class FileSystemInformation:
         self._directories = []
         self._all_entries = []
 
-    def get_size(self, path):
-        if path in self._size_cache:
-            return self._size_cache[path]
+    def get_atime(self, path):
+        stats = os.stat(path)
+        return stats[7]
 
-        mode, inode, device_id, link_count, uid, gid, size, atime, mtime, ctime = os.stat(path)
+    def get_size(self, path):
+        # mode, inode, device_id, link_count, uid, gid, size, atime, mtime, ctime
+        mode, _, _, _, _, _, size, _, _, _ = os.stat(path)
         b = 0
         if mode == 0x8000:
             b = size
         self._size_cache[path] = b
         return b
 
+    def is_dir(self, path):
+        return os.stat(path)[0] == 0x4000
+
     def update_file_system_info(self):
         if len(self._vfs_stats) == 0:
             self._vfs_stats = os.statvfs("/")
@@ -77,7 +93,13 @@ class FileSystemInformation:
     def print_fs_tree(self):
         for fn in self._all_entries:
             spacer = " " * (self._longest_path + 2 - len(fn))
+
             size = self.get_size(fn)
+            is_dir = self.is_dir(fn)
             size_spacer = " " * (6 - len(str(size)))
+            a_time = " " * 19
+            if not self.is_dir(fn):
+                t = self.get_atime(fn)
+                a_time = human_readable_time(t)
 
-            print(f"{fn}{spacer} {size_spacer}{size} bytes")
+            print(f"{fn}{spacer} {size_spacer}{size} bytes -- {a_time}")
diff --git a/umnp/tools/list-files.py b/umnp/tools/list-files.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6a390374e45d7091984e8ab217649e48181957e
--- /dev/null
+++ b/umnp/tools/list-files.py
@@ -0,0 +1,118 @@
+import os
+import time
+
+
+def human_readable_time(timestamp: int):
+    t = time.localtime(timestamp)
+    year, month, day = t[0:3]
+    hour, minute, second = t[3:6]
+    return f"{year}-{month:02d}-{day:02d}T{hour:02d}:{minute:02d}:{second:02d}"
+
+
+def fs_entries_recurse(current_directory="", total_path="/"):
+    files = []
+    dirs = []
+    all_entries = []
+
+    for fstats in os.ilistdir(total_path):
+        fn = fstats[0]
+        ft = fstats[1]
+        if total_path.endswith("/"):
+            target = total_path + fn
+        else:
+            target = total_path + "/" + fn
+        all_entries.append(target)
+        if ft == 0x4000:
+            dirs.append(total_path + "/" + fn)
+
+            f, current_directory, a = fs_entries_recurse(fn, target)
+            files.extend(f)
+            dirs.extend(current_directory)
+            all_entries.extend(a)
+        else:
+            files.append(target)
+    return files, dirs, all_entries
+
+
+class FileSystemInformation:
+    def __init__(self):
+        self._size_cache = {}
+        self._atime_cache = {}
+        self._files = []
+        self._directories = []
+        self._all_entries = []
+        self._vfs_stats = []
+        self._usage = None
+        self._size_free_kb = None
+        self._blocks_free = None
+        self._max_fn_len = None
+        self._blocks_used = None
+        self._block_size = None
+        self._size_total_kb = None
+        self._longest_path = 0
+        self.update_file_system_info()
+        self._files, self._directories, self._all_entries = fs_entries_recurse("")
+        for fn in self._all_entries:
+            self._longest_path = max(self._longest_path, len(fn))
+
+    def invalidate(self):
+        self._size_cache = {}
+        self._vfs_stats = []
+        self._files = []
+        self._directories = []
+        self._all_entries = []
+
+    def get_atime(self, path):
+        stats = os.stat(path)
+        return stats[7]
+
+    def get_size(self, path):
+        # mode, inode, device_id, link_count, uid, gid, size, atime, mtime, ctime
+        mode, _, _, _, _, _, size, _, _, _ = os.stat(path)
+        b = 0
+        if mode == 0x8000:
+            b = size
+        self._size_cache[path] = b
+        return b
+
+    def is_dir(self, path):
+        return os.stat(path)[0] == 0x4000
+
+    def update_file_system_info(self):
+        if len(self._vfs_stats) == 0:
+            self._vfs_stats = os.statvfs("/")
+
+        b_size, _, b_count, b_count_free, _, _, _, _, _, max_fn_len = self._vfs_stats
+        self._usage = 1 - b_count_free / b_count
+        self._size_free_kb = b_size * b_count_free / 1024
+        self._blocks_free = b_count_free
+        self._blocks_used = b_count - b_count_free
+        self._block_size = b_size
+        self._size_total_kb = b_size * b_count / 1024
+        self._max_fn_len = max_fn_len
+
+    def print_fs_tree(self):
+        for fn in self._all_entries:
+            spacer = " " * (self._longest_path + 2 - len(fn))
+
+            size = self.get_size(fn)
+            is_dir = self.is_dir(fn)
+            size_spacer = " " * (6 - len(str(size)))
+            a_time = "?" + " " * 18
+            if is_dir:
+                ft = "d"
+            else:
+                ft = "f"
+            if not self.is_dir(fn):
+                t = self.get_atime(fn)
+                a_time = human_readable_time(t)
+
+            print(f"{fn}{spacer} {size_spacer}{size} bytes {ft} {a_time}")
+
+
+def main():
+    fs = FileSystemInformation()
+    fs.print_fs_tree()
+
+
+main()