diff --git a/oscar/SleepLib/deviceconnection.cpp b/oscar/SleepLib/deviceconnection.cpp
index b5792346..58743935 100644
--- a/oscar/SleepLib/deviceconnection.cpp
+++ b/oscar/SleepLib/deviceconnection.cpp
@@ -10,6 +10,8 @@
 #include "version.h"
 #include <QtSerialPort/QSerialPortInfo>
 #include <QFile>
+#include <QFileInfo>
+#include <QDir>
 #include <QBuffer>
 #include <QDateTime>
 #include <QDebug>
@@ -32,14 +34,18 @@ public:
     XmlRecorder(class QFile * file, const QString & tag = XmlRecorder::TAG);
     XmlRecorder(QString & string, const QString & tag = XmlRecorder::TAG);
     virtual ~XmlRecorder();
+    XmlRecorder* close();
     inline QXmlStreamWriter & xml() { return *m_xml; }
     inline void lock() { m_mutex.lock(); }
     inline void unlock() { m_mutex.unlock(); }
 protected:
+    XmlRecorder(XmlRecorder* parent, const QString & id, const QString & tag);
+    QXmlStreamWriter* addSubstream(XmlRecorder* child, const QString & id);
     const QString m_tag;
     QFile* m_file;  // nullptr for non-file recordings
     QXmlStreamWriter* m_xml;
     QMutex m_mutex;
+    XmlRecorder* m_parent;
     
     virtual void prologue();
     virtual void epilogue();
@@ -54,13 +60,18 @@ public:
     XmlReplay(class QFile * file, const QString & tag = XmlRecorder::TAG);
     XmlReplay(QXmlStreamReader & xml, const QString & tag = XmlRecorder::TAG);
     virtual ~XmlReplay();
+    XmlReplay* close();
     template<class T> inline T* getNextEvent(const QString & id = "");
 
 
 protected:
+    XmlReplay(XmlReplay* parent, const QString & id, const QString & tag = XmlRecorder::TAG);
+    QXmlStreamReader* findSubstream(XmlReplay* child, const QString & id);
+
     void deserialize(QXmlStreamReader & xml);
     void deserializeEvents(QXmlStreamReader & xml);
     const QString m_tag;
+    QFile* m_file;
 
     QHash<QString,QHash<QString,QList<XmlReplayEvent*>>> m_eventIndex;
     QHash<QString,QHash<QString,int>> m_indexPosition;
@@ -75,6 +86,8 @@ protected:
     inline void unlock() { m_lock.unlock(); }
     void processPendingSignals(const QObject* target);
     friend class XmlReplayLock;
+
+    XmlReplay* m_parent;
 };
 
 class XmlReplayEvent
@@ -86,9 +99,10 @@ public:
     virtual const QString id() const { static const QString none(""); return none; }
     virtual bool randomAccess() const { return false; }
 
-    void record(XmlRecorder* xml);
+    void record(XmlRecorder* xml) const;
     friend QXmlStreamWriter & operator<<(QXmlStreamWriter & xml, const XmlReplayEvent & event);
     friend QXmlStreamReader & operator>>(QXmlStreamReader & xml, XmlReplayEvent & event);
+    void writeTag(QXmlStreamWriter & xml) const;
 
     typedef XmlReplayEvent* (*FactoryMethod)();
     static bool registerClass(const QString & tag, FactoryMethod factory);
@@ -148,6 +162,12 @@ public:
         m_keys = other->m_keys;
         m_data = other->m_data;
     }
+protected:
+    void copy(const XmlReplayEvent & other)
+    {
+        copyIf(&other);
+        m_time = other.m_time;
+    }
 
 protected:
     static QHash<QString,FactoryMethod> s_factories;
@@ -219,22 +239,83 @@ protected:
     XmlReplay* m_replay;
 };
 
+static QString substreamFilepath(QFile* parent, const QString & id)
+{
+    Q_ASSERT(parent);
+    QFileInfo info(*parent);
+    QString path = info.canonicalPath() + QDir::separator() + info.completeBaseName() + "-" + id + ".xml";
+    return path;
+}
+
+
 XmlRecorder::XmlRecorder(QFile* stream, const QString & tag)
-    : m_tag(tag), m_file(stream), m_xml(new QXmlStreamWriter(stream))
+    : m_tag(tag), m_file(stream), m_xml(new QXmlStreamWriter(stream)), m_parent(nullptr)
 {
     prologue();
 }
 
 XmlRecorder::XmlRecorder(QString & string, const QString & tag)
-    : m_tag(tag), m_file(nullptr), m_xml(new QXmlStreamWriter(&string))
+    : m_tag(tag), m_file(nullptr), m_xml(new QXmlStreamWriter(&string)), m_parent(nullptr)
 {
     prologue();
 }
 
+// Protected constructor for substreams.
+XmlRecorder::XmlRecorder(XmlRecorder* parent, const QString & id, const QString & tag)
+    : m_tag(tag), m_file(nullptr), m_xml(nullptr), m_parent(parent)
+{
+    Q_ASSERT(m_parent);
+    m_xml = m_parent->addSubstream(this, id);
+    if (m_xml == nullptr) {
+        qWarning() << "Not recording" << id;
+        static QString null;
+        m_xml = new QXmlStreamWriter(&null);
+    }
+
+    m_xml->setAutoFormatting(true);
+    m_xml->setAutoFormattingIndent(2);
+    // Substreams handle their own prologue.
+}
+
+QXmlStreamWriter* XmlRecorder::addSubstream(XmlRecorder* child, const QString & id)
+{
+    Q_ASSERT(child);
+    QXmlStreamWriter* xml = nullptr;
+
+    if (m_file) {
+        QString childPath = substreamFilepath(m_file, id);
+        child->m_file = new QFile(childPath);
+        if (child->m_file->open(QIODevice::WriteOnly | QIODevice::Append)) {
+            xml = new QXmlStreamWriter(child->m_file);
+            qDebug() << "Recording to" << childPath;
+        } else {
+            qWarning() << "Unable to open" << childPath << "for writing";
+        }
+    } else {
+        qWarning() << "String-based substreams are not supported";
+        // Maybe some day support string-based substreams:
+        // - parent passes string to child
+        // - on connectionClosed, parent asks recorder to flush string to stream
+    }
+    
+    return xml;
+}
+
 XmlRecorder::~XmlRecorder()
 {
     epilogue();
     delete m_xml;
+    // file substreams manage their own file
+    if (m_parent && m_file) {
+        delete m_file;
+    }
+}
+
+XmlRecorder* XmlRecorder::close()
+{
+    auto parent = m_parent;
+    delete this;
+    return parent;
 }
 
 void XmlRecorder::prologue()
@@ -252,23 +333,76 @@ void XmlRecorder::epilogue()
 }
 
 XmlReplay::XmlReplay(QFile* file, const QString & tag)
-    : m_tag(tag), m_pendingSignal(nullptr)
+    : m_tag(tag), m_file(file), m_pendingSignal(nullptr), m_parent(nullptr)
 {
+    Q_ASSERT(file);
+    QFileInfo info(*file);
+    qDebug() << "Replaying from" << info.canonicalFilePath();
+
     QXmlStreamReader xml(file);
     deserialize(xml);
 }
 
 XmlReplay::XmlReplay(QXmlStreamReader & xml, const QString & tag)
-    : m_tag(tag), m_pendingSignal(nullptr)
+    : m_tag(tag), m_file(nullptr), m_pendingSignal(nullptr), m_parent(nullptr)
 {
     deserialize(xml);
 }
 
+XmlReplay::XmlReplay(XmlReplay* parent, const QString & id, const QString & tag)
+    : m_tag(tag), m_file(nullptr), m_pendingSignal(nullptr), m_parent(parent)
+{
+    Q_ASSERT(m_parent);
+
+    auto xml = m_parent->findSubstream(this, id);
+    if (xml) {
+        deserialize(*xml);
+        delete xml;
+    } else {
+        qWarning() << "Not replaying" << id;
+    }
+}
+
+QXmlStreamReader* XmlReplay::findSubstream(XmlReplay* child, const QString & id)
+{
+    Q_ASSERT(child);
+    QXmlStreamReader* xml = nullptr;
+
+    if (m_file) {
+        QString childPath = substreamFilepath(m_file, id);
+        child->m_file = new QFile(childPath);
+        if (child->m_file->open(QIODevice::ReadOnly)) {
+            xml = new QXmlStreamReader(child->m_file);
+            qDebug() << "Replaying from" << childPath;
+        } else {
+            qWarning() << "Unable to open" << childPath << "for reading";
+        }
+    } else {
+        qWarning() << "String-based substreams are not supported";
+        // Maybe some day support string-based substreams:
+        // - when deserializing, use e.g. ConnectionEvents to cache the substream strings
+        // - then return a QXmlStreamReader here using that string
+    }
+ 
+    return xml;
+}
+
 XmlReplay::~XmlReplay()
 {
     for (auto event : m_events) {
         delete event;
     }
+    // file substreams manage their own file
+    if (m_parent && m_file) {
+        delete m_file;
+    }
+}
+
+XmlReplay* XmlReplay::close()
+{
+    auto parent = m_parent;
+    delete this;
+    return parent;
 }
 
 void XmlReplay::deserialize(QXmlStreamReader & xml)
@@ -399,7 +533,7 @@ XmlReplayEvent::XmlReplayEvent()
 {
 }
 
-void XmlReplayEvent::record(XmlRecorder* writer)
+void XmlReplayEvent::record(XmlRecorder* writer) const
 {
     // Do nothing if we're not recording.
     if (writer != nullptr) {
@@ -431,20 +565,24 @@ XmlReplayEvent* XmlReplayEvent::createInstance(const QString & tag)
     return event;
 }
 
-QXmlStreamWriter & operator<<(QXmlStreamWriter & xml, const XmlReplayEvent & event)
+void XmlReplayEvent::writeTag(QXmlStreamWriter & xml) const
 {
-    QDateTime time = event.m_time.toOffsetFromUtc(event.m_time.offsetFromUtc());  // force display of UTC offset
+    QDateTime time = m_time.toOffsetFromUtc(m_time.offsetFromUtc());  // force display of UTC offset
 #if QT_VERSION < QT_VERSION_CHECK(5,9,0)
     // TODO: Can we please deprecate support for Qt older than 5.9?
     QString timestamp = time.toString(Qt::ISODate);
 #else
     QString timestamp = time.toString(Qt::ISODateWithMs);
 #endif
-    xml.writeStartElement(event.tag());
+    xml.writeStartElement(tag());
     xml.writeAttribute("time", timestamp);
 
-    event.write(xml);
+    write(xml);
+}
 
+QXmlStreamWriter & operator<<(QXmlStreamWriter & xml, const XmlReplayEvent & event)
+{
+    event.writeTag(xml);
     xml.writeEndElement();
     return xml;
 }
@@ -516,16 +654,11 @@ template<> const bool XmlReplayBase<type>::registered = XmlReplayEvent::register
 
 class DeviceRecorder : public XmlRecorder
 {
-    virtual void prologue()
-    {
-        XmlRecorder::prologue();
-        m_xml->writeAttribute("oscar", getVersion().toString());
-    }
 public:
     static const QString TAG;
 
-    DeviceRecorder(class QFile * file) : XmlRecorder(file, DeviceRecorder::TAG) {}
-    DeviceRecorder(QString & string) : XmlRecorder(string, DeviceRecorder::TAG) {}
+    DeviceRecorder(class QFile * file) : XmlRecorder(file, DeviceRecorder::TAG) { m_xml->writeAttribute("oscar", getVersion().toString()); }
+    DeviceRecorder(QString & string) : XmlRecorder(string, DeviceRecorder::TAG) { m_xml->writeAttribute("oscar", getVersion().toString()); }
 };
 const QString DeviceRecorder::TAG = "devicereplay";
 
@@ -616,7 +749,6 @@ DeviceConnection* DeviceConnectionManager::openConnection(const QString & type,
     } else {
         qWarning() << "unable to create" << type << "connection to" << name;
     }
-    // TODO: record event
     return conn;
 }
 
@@ -635,7 +767,6 @@ void DeviceConnectionManager::connectionClosed(DeviceConnection* conn)
     } else {
         qWarning() << type << "connection to" << name << "missing";
     }
-    // TODO: record event
 }
 
 // Temporary convenience function for code that still supports only serial ports.
@@ -809,24 +940,41 @@ bool SerialPortInfo::operator==(const SerialPortInfo & other) const
 }
 
 
+// TODO: restrict constructor to OpenConnectionEvent
+class ConnectionEvent : public XmlReplayBase<ConnectionEvent>
+{
+public:
+    ConnectionEvent() { Q_ASSERT(false); }  // Implement if we ever support string-based substreams
+    ConnectionEvent(const XmlReplayEvent & trigger)
+    {
+        copy(trigger);
+    }
+    virtual const QString id() const
+    {
+        QString time = m_time.toString("yyyyMMdd.HHmmss.zzz");
+        return m_values["name"] + "-" + time;
+    }
+};
+REGISTER_XMLREPLAYEVENT("connection", ConnectionEvent);
+
 // MARK: -
 // MARK: Device connection base class
 
 class ConnectionRecorder : public XmlRecorder
 {
 public:
-    static const QString TAG;
-
-    ConnectionRecorder(class QFile * file) : XmlRecorder(file, ConnectionRecorder::TAG) {}
-    ConnectionRecorder(QString & string) : XmlRecorder(string, ConnectionRecorder::TAG) {}
+    ConnectionRecorder(XmlRecorder* parent, const ConnectionEvent& event) : XmlRecorder(parent, event.id(), event.tag())
+    {
+        Q_ASSERT(m_xml);
+        event.writeTag(*m_xml);
+        m_xml->writeAttribute("oscar", getVersion().toString());
+    }
 };
-const QString ConnectionRecorder::TAG = "connection";
 
 class ConnectionReplay : public XmlReplay
 {
 public:
-    ConnectionReplay(class QFile * file) : XmlReplay(file, ConnectionRecorder::TAG) {}
-    ConnectionReplay(QXmlStreamReader & xml) : XmlReplay(xml, ConnectionRecorder::TAG) {}
+    ConnectionReplay(XmlReplay* parent, const ConnectionEvent& event) : XmlReplay(parent, event.id(), event.tag()) {}
 };
 
 
@@ -965,13 +1113,16 @@ bool SerialPortConnection::open()
         return false;
     }
     XmlReplayLock lock(this, m_replay);
+    OpenConnectionEvent* replayEvent = nullptr;
     OpenConnectionEvent event("serial", m_name);
 
     if (!m_replay) {
+        // TODO: move this into SerialPortConnection::openDevice() and move everything
+        // else up to DeviceConnection::open().
         m_port.setPortName(m_name);
         checkResult(m_port.open(QSerialPort::ReadWrite), event);
     } else {
-        auto replayEvent = m_replay->getNextEvent<OpenConnectionEvent>(m_name);
+        replayEvent = m_replay->getNextEvent<OpenConnectionEvent>(event.id());
         if (replayEvent) {
             event.copyIf(replayEvent);
         } else {
@@ -981,8 +1132,19 @@ bool SerialPortConnection::open()
 
     event.record(m_record);
     m_opened = event.ok();
-    
-    // TODO: if OK, open a connection substream for connection events
+
+    if (m_opened) {
+        // open a connection substream for connection events
+        if (m_record) {
+            ConnectionEvent connEvent(event);
+            m_record = new ConnectionRecorder(m_record, connEvent);
+        }
+        if (m_replay) {
+            Q_ASSERT(replayEvent);
+            ConnectionEvent connEvent(*replayEvent);  // we need to use the replay's timestamp to find the referenced substream
+            m_replay = new ConnectionReplay(m_replay, connEvent);
+        }
+    }
 
     return event.ok();
 }
@@ -1211,22 +1373,30 @@ bool SerialPortConnection::flush()
 
 void SerialPortConnection::close()
 {
+    if (m_opened) {
+        // close event substream first
+        if (m_record) {
+            m_record = m_record->close();
+        }
+        if (m_replay) {
+            m_replay = m_replay->close();
+        }
+    }
+
     XmlReplayLock lock(this, m_replay);
     CloseConnectionEvent event("serial", m_name);
 
-    // TODO: the separate connection stream will have an enclosing "connection" tag with these
-    // attributes. The main device connection manager stream will log this openConnection/
-    // closeConnection pair. We'll also need to include a loader ID and stream version number
+    // TODO: We'll also need to include a loader ID and stream version number
     // in the "connection" tag, so that if we ever have to change a loader's download code,
     // the older replays will still work as expected.
 
-    // TODO: close event substream first
-
     if (!m_replay) {
+        // TODO: move this into SerialPortConnection::closeDevice() and move everything
+        // else up to DeviceConnection::close().
         m_port.close();
         checkError(event);
     } else {
-        auto replayEvent = m_replay->getNextEvent<CloseConnectionEvent>(m_name);
+        auto replayEvent = m_replay->getNextEvent<CloseConnectionEvent>(event.id());
         if (replayEvent) {
             event.copyIf(replayEvent);
         } else {
diff --git a/oscar/tests/deviceconnectiontests.cpp b/oscar/tests/deviceconnectiontests.cpp
index fec07016..100b79d8 100644
--- a/oscar/tests/deviceconnectiontests.cpp
+++ b/oscar/tests/deviceconnectiontests.cpp
@@ -99,6 +99,7 @@ static void testDownload(const QString & loaderName)
     Q_ASSERT(oxi);
 
     if (oxi->openDevice()) {
+        bool open = true;
         oxi->resetDevice();
         int session_count = oxi->getSessionCount();
         qDebug() << session_count << "sessions";
@@ -116,10 +117,12 @@ static void testDownload(const QString & loaderName)
                     QCoreApplication::processEvents();
                 }
             }
-            oxi->openDevice();  // annoyingly import currently closes the device, so reopen it
+            open = oxi->openDevice();  // annoyingly import currently closes the device, so reopen it
+        }
+        if (open) {
+            oxi->closeDevice();
         }
     }
-    oxi->closeDevice();
     oxi->trashRecords();
 }
 
@@ -132,11 +135,11 @@ void DeviceConnectionTests::testOximeterConnection()
     const char* argv = "test";
     QCoreApplication app(argc, (char**) &argv);
     
-    QString string;
     DeviceConnectionManager & devices = DeviceConnectionManager::getInstance();
+    /*
+    QString string;
     devices.record(string);
 
-    /*
     // new API
     QString portName = "cu.SLAB_USBtoUART";
     {