diff --git a/oscar/Graphs/gSessionTimesChart.cpp b/oscar/Graphs/gSessionTimesChart.cpp index 14004373..71c31171 100644 --- a/oscar/Graphs/gSessionTimesChart.cpp +++ b/oscar/Graphs/gSessionTimesChart.cpp @@ -896,10 +896,10 @@ void gSessionTimesChart::paint(QPainter &painter, gGraph &graph, const QRegion & float s2 = double(slice.end - slice.start) / 3600000.0; - QColor col = (slice.status == EquipmentOn) ? goodcolor : Qt::black; + QColor col = (slice.status == MaskOn) ? goodcolor : Qt::black; QString txt = QObject::tr("%1\nLength: %3\nStart: %2\n").arg(datestr).arg(st.time().toString("hh:mm:ss")).arg(s2,0,'f',2); - txt += (slice.status == EquipmentOn) ? QObject::tr("Mask On") : QObject::tr("Mask Off"); + txt += (slice.status == MaskOn) ? QObject::tr("Mask On") : QObject::tr("Mask Off"); slices.append(SummaryChartSlice(&calcitems[0], s1, s2, txt, col)); } } else { diff --git a/oscar/SleepLib/day.cpp b/oscar/SleepLib/day.cpp index fbbb13e3..d01e8af2 100644 --- a/oscar/SleepLib/day.cpp +++ b/oscar/SleepLib/day.cpp @@ -654,13 +654,19 @@ qint64 Day::total_time() range.insert(first, 0); range.insert(last, 1); d_totaltime += sess->length(); + if (sess->length() == 0) { + qWarning() << sess->s_session << "0 length session"; + } } } else { for (auto & slice : sess->m_slices) { - if (slice.status == EquipmentOn) { + if (slice.status == MaskOn) { range.insert(slice.start, 0); range.insert(slice.end, 1); d_totaltime += slice.end - slice.start; + if (slice.end - slice.start == 0) { + qWarning() << sess->s_session << "0 length slice"; + } } } } @@ -724,13 +730,19 @@ qint64 Day::total_time(MachineType type) range.insert(first, 0); range.insert(last, 1); d_totaltime += sess->length(); + if (sess->length() == 0) { + qWarning() << sess->s_session << "0 length session"; + } } } else { for (const auto & slice : sess->m_slices) { - if (slice.status == EquipmentOn) { + if (slice.status == MaskOn) { range.insert(slice.start, 0); range.insert(slice.end, 1); d_totaltime += slice.end - slice.start; + if (slice.end - slice.start == 0) { + qWarning() << sess->s_session << "0 length slice"; + } } } } diff --git a/oscar/SleepLib/event.cpp b/oscar/SleepLib/event.cpp index a90bdbb1..6eda1143 100644 --- a/oscar/SleepLib/event.cpp +++ b/oscar/SleepLib/event.cpp @@ -1,4 +1,4 @@ -/* SleepLib Event Class Implementation +/* SleepLib Event Class Implementation * * Copyright (c) 2011-2018 Mark Watkins <mark@jedimark.net> * @@ -85,12 +85,12 @@ void EventList::AddEvent(qint64 time, EventStoreType data) if (m_first > time) { // Crud.. Update all the previous records // This really shouldn't happen. - qDebug() << "Unordered time detected in AddEvent()."; + qDebug() << "Unordered time detected in AddEvent()" << m_count << m_first << time << data; qint32 delta = (m_first - time); for (quint32 i = 0; i < m_count; ++i) { - m_time[i] -= delta; + m_time[i] += delta; } m_first = time; diff --git a/oscar/SleepLib/loader_plugins/prs1_loader.cpp b/oscar/SleepLib/loader_plugins/prs1_loader.cpp index 0b9af0b9..8fc836d3 100644 --- a/oscar/SleepLib/loader_plugins/prs1_loader.cpp +++ b/oscar/SleepLib/loader_plugins/prs1_loader.cpp @@ -214,13 +214,13 @@ struct PRS1TestedModel }; static const PRS1TestedModel s_PRS1TestedModels[] = { - { "251P", 0, 2 }, - { "450P", 0, 3 }, - { "451P", 0, 3 }, - { "550P", 0, 2 }, - { "550P", 0, 3 }, - { "551P", 0, 2 }, - { "750P", 0, 2 }, + { "251P", 0, 2 }, // "REMstar Plus (Philips Respironics)" (brick) + { "450P", 0, 3 }, // "REMstar Pro (Philips Respironics)" + { "451P", 0, 3 }, // "REMstar Pro (Philips Respironics)" + { "550P", 0, 2 }, // "REMstar Auto (Philips Respironics)" + { "550P", 0, 3 }, // "REMstar Auto (Philips Respironics)" + { "551P", 0, 2 }, // "REMstar Auto (Philips Respironics)" + { "750P", 0, 2 }, // "BiPAP Auto (Philips Respironics)" { "460P", 0, 4 }, { "461P", 0, 4 }, @@ -230,15 +230,15 @@ static const PRS1TestedModel s_PRS1TestedModels[] = { { "660P", 0, 4 }, { "760P", 0, 4 }, - { "200X110", 0, 6 }, - { "400G110", 0, 6 }, - { "400X110", 0, 6 }, + { "200X110", 0, 6 }, // "DreamStation CPAP" (brick) + { "400G110", 0, 6 }, // "DreamStation Go" + { "400X110", 0, 6 }, // "DreamStation CPAP Pro" { "400X150", 0, 6 }, - { "500X110", 0, 6 }, + { "500X110", 0, 6 }, // "DreamStation Auto CPAP" { "500X150", 0, 6 }, - { "502G150", 0, 6 }, - { "600X110", 0, 6 }, - { "700X110", 0, 6 }, + { "502G150", 0, 6 }, // "DreamStation Go Auto" + { "600X110", 0, 6 }, // "DreamStation BiPAP Pro" + { "700X110", 0, 6 }, // "DreamStation Auto BiPAP" { "950P", 5, 0 }, { "960P", 5, 1 }, @@ -277,6 +277,14 @@ bool PRS1ModelInfo::IsTested(const QString & model, int family, int familyVersio if (m_testedModels.value(family).value(familyVersion).contains(model)) { return true; } + // Some 500X150 C0/C1 folders have contained this bogus model number in their PROP.TXT file, + // with the same serial number seen in the main PROP.TXT file that shows the real model number. + if (model == "100X100") { +#ifndef UNITTEST_MODE + qDebug() << "Ignoring 100X100 for untested alert"; +#endif + return true; + } return false; }; @@ -978,6 +986,10 @@ void PRS1Loader::ScanFiles(const QStringList & paths, int sessionid_base, Machin QString path = fi.canonicalFilePath(); bool ok; + if (fi.fileName() == ".DS_Store") { + continue; + } + QString ext_s = fi.fileName().section(".", -1); ext = ext_s.toInt(&ok); if (!ok) { @@ -1003,7 +1015,10 @@ void PRS1Loader::ScanFiles(const QStringList & paths, int sessionid_base, Machin } */ - + // TODO: BUG: This isn't right, since files can have multiple session + // chunks, which might not correspond to the filename. But before we can + // fix this we need to come up with a reasonably fast way to filter previously + // imported files without re-reading all of them. if (m->SessionExists(sid)) { // Skip already imported session qDebug() << path << "session already exists, skipping" << sid; @@ -1036,13 +1051,6 @@ void PRS1Loader::ScanFiles(const QStringList & paths, int sessionid_base, Machin } // Parse the data chunks and read the files.. - if (fi.canonicalFilePath().isEmpty()) { -#if QT_VERSION < QT_VERSION_CHECK(5,12,0) - qWarning() << fi.fileName() << "canonicalFilePath is empty"; -#else - qWarning() << fi << "cannonicalFilePath is empty"; -#endif - } QList<PRS1DataChunk *> Chunks = ParseFile(fi.canonicalFilePath()); for (int i=0; i < Chunks.size(); ++i) { @@ -1053,22 +1061,11 @@ void PRS1Loader::ScanFiles(const QStringList & paths, int sessionid_base, Machin PRS1DataChunk * chunk = Chunks.at(i); - if (ext <= 1) { - const unsigned char * data = (unsigned char *)chunk->m_data.constData(); - - if (data[0x00] != 0) { - // 5 length 5, 6 length 1, 7 length 3, 8 length 3 seen on 960P - qWarning() << path << "data doesn't start with 0, skipping:" << data[0x00] << chunk->m_data.size(); - delete chunk; - continue; - } - } - SessionID chunk_sid = chunk->sessionid; - if (i > 0 || chunk_sid != sid) { // log multiple chunks in non-waveform files and session ID mismatches + if (i == 0 && chunk_sid != sid) { // log session ID mismatches qDebug() << fi.canonicalFilePath() << chunk_sid; } - if (m->SessionExists(sid)) { // BUG: this should presumably be chunk_sid, but any change needs to be tested. + if (m->SessionExists(chunk_sid)) { qDebug() << path << "session already exists, skipping" << sid << chunk_sid; delete chunk; continue; @@ -1316,7 +1313,7 @@ public: { m_pos = pos; m_data = data.mid(pos, len); - Q_ASSERT(m_data.size() >= 3); + Q_ASSERT(m_data.size() >= 1); m_code = m_data.at(0); } }; @@ -1382,19 +1379,18 @@ public: } }; -class PRS1ParsedSliceEvent : public PRS1ParsedDurationEvent +class PRS1ParsedSliceEvent : public PRS1ParsedValueEvent { public: virtual QMap<QString,QString> contents(void) { QMap<QString,QString> out; out["start"] = timeStr(m_start); - out["duration"] = timeStr(m_duration); QString s; - switch (m_status) { - case EquipmentOn: s = "EquipmentOn"; break; + switch ((SliceStatus) m_value) { + case MaskOn: s = "MaskOn"; break; + case MaskOff: s = "MaskOff"; break; case EquipmentOff: s = "EquipmentOff"; break; - case EquipmentLeaking: s = "EquipmentLeaking"; break; case UnknownStatus: s = "Unknown"; break; } out["status"] = s; @@ -1402,9 +1398,8 @@ public: } static const PRS1ParsedEventType TYPE = EV_PRS1_SLICE; - SliceStatus m_status; - PRS1ParsedSliceEvent(int start, int duration, SliceStatus status) : PRS1ParsedDurationEvent(TYPE, start, duration), m_status(status) {} + PRS1ParsedSliceEvent(int start, SliceStatus status) : PRS1ParsedValueEvent(TYPE, start, (int) status) {} }; @@ -1717,7 +1712,7 @@ bool PRS1DataChunk::ParseEventsF5V3(void) { if (this->family != 5 || this->familyVersion != 3) { qWarning() << "ParseEventsF5V3 called with family" << this->family << "familyVersion" << this->familyVersion; - //break; // don't break to avoid changing behavior (for now) + return false; } EventDataType data0, data1, data2, data3, data4, data5; @@ -2437,7 +2432,7 @@ bool PRS1DataChunk::ParseEventsF3V6(void) if (this->family != 3 || this->familyVersion != 6) { qWarning() << "ParseEventsF3V6 called with family" << this->family << "familyVersion" << this->familyVersion; - //break; // don't break to avoid changing behavior (for now) + return false; } int t = 0; @@ -2623,7 +2618,7 @@ bool PRS1DataChunk::ParseEventsF3V3(void) { if (this->family != 3 || this->familyVersion != 3) { qWarning() << "ParseEventsF3V3 called with family" << this->family << "familyVersion" << this->familyVersion; - //break; // don't break to avoid changing behavior (for now) + return false; } int t = 0, tt; @@ -3079,7 +3074,7 @@ bool PRS1DataChunk::ParseEventsF0(CPAPMode mode) pos += 2; data1 = buffer[pos++]; - this->AddEvent(new PRS1UnknownValueEvent(code, t - data1, data0)); + this->AddEvent(new PRS1UnknownValueEvent(code, t - data1, data0)); // TODO: start time should probably match PB below break; case 0x0f: // Cheyne Stokes Respiration @@ -3090,7 +3085,11 @@ bool PRS1DataChunk::ParseEventsF0(CPAPMode mode) } pos += 2; data1 = buffer[pos++]; - this->AddEvent(new PRS1PeriodicBreathingEvent(t - data1, data0)); + if (this->familyVersion == 2 || this->familyVersion == 3) { + this->AddEvent(new PRS1PeriodicBreathingEvent(t - data1 - data0, data0)); // PB event appears data1 seconds after conclusion + } else { + this->AddEvent(new PRS1PeriodicBreathingEvent(t - data1, data0)); // TODO: this should probably be the same as F0V23, but it hasn't been tested + } break; case 0x10: // Large Leak @@ -3101,7 +3100,7 @@ bool PRS1DataChunk::ParseEventsF0(CPAPMode mode) } pos += 2; data1 = buffer[pos++]; - this->AddEvent(new PRS1LargeLeakEvent(t - data1, data0)); + this->AddEvent(new PRS1LargeLeakEvent(t - data1, data0)); // TODO: start time should probably match PB above break; case 0x11: // Leak Rate & Snore Graphs @@ -3156,7 +3155,7 @@ bool PRS1DataChunk::ParseEventsF0(CPAPMode mode) } -bool PRS1Import::ParseCompliance() +bool PRS1Import::ImportCompliance() { bool ok; ok = compliance->ParseCompliance(); @@ -3167,9 +3166,11 @@ bool PRS1Import::ParseCompliance() if (e->m_type == PRS1ParsedSliceEvent::TYPE) { PRS1ParsedSliceEvent* s = (PRS1ParsedSliceEvent*) e; qint64 tt = start + qint64(s->m_start) * 1000L; - qint64 duration = qint64(s->m_duration) * 1000L; - session->m_slices.append(SessionSlice(tt, tt + duration, s->m_status)); - qDebug() << compliance->sessionid << "Added Slice" << tt << (tt+duration) << s->m_status; + if (!session->m_slices.isEmpty()) { + SessionSlice & prevSlice = session->m_slices.last(); + prevSlice.end = tt; + } + session->m_slices.append(SessionSlice(tt, tt, (SliceStatus) s->m_value)); continue; } else if (e->m_type != PRS1ParsedSettingEvent::TYPE) { qWarning() << "Compliance had non-setting event:" << (int) e->m_type; @@ -3210,6 +3211,11 @@ bool PRS1Import::ParseCompliance() if (!ok) { return false; } + if (compliance->duration == 0) { + // This does occasionally happen and merely indicates a brief session with no useful data. + //qDebug() << compliance->sessionid << "duration == 0"; + return false; + } session->setSummaryOnly(true); session->set_first(start); session->set_last(qint64(compliance->timestamp + compliance->duration) * 1000L); @@ -3218,78 +3224,161 @@ bool PRS1Import::ParseCompliance() } +// TODO: have UNEXPECTED_VALUE set a flag in the importer/machine that this data set is unusual +#define UNEXPECTED_VALUE(SRC, VALS) { qWarning() << this->sessionid << QString("%1: %2 = %3 != %4").arg(__func__).arg(#SRC).arg(SRC).arg(VALS); } +#define CHECK_VALUE(SRC, VAL) if ((SRC) != (VAL)) UNEXPECTED_VALUE(SRC, VAL) +#define CHECK_VALUES(SRC, VAL1, VAL2) if ((SRC) != (VAL1) && (SRC) != (VAL2)) UNEXPECTED_VALUE(SRC, #VAL1 " or " #VAL2) +// for more than 2 values, just write the test manually and use UNEXPECTED_VALUE if it fails + bool PRS1DataChunk::ParseCompliance(void) { - const unsigned char * data = (unsigned char *)this->m_data.constData(); + switch (this->family) { + case 0: + if (this->familyVersion == 6) { + return this->ParseComplianceF0V6(); + } else if (this->familyVersion == 2 || this->familyVersion == 3) { + return this->ParseComplianceF0V23(); + } + default: + ; + } - if (data[0x00] > 0) { + qWarning() << "unexpected family" << this->family << "familyVersion" << this->familyVersion; + return false; +} + + +bool PRS1DataChunk::ParseComplianceF0V23(void) +{ + if (this->family != 0 || (this->familyVersion != 2 && this->familyVersion != 3)) { + qWarning() << "ParseComplianceF0V23 called with family" << this->family << "familyVersion" << this->familyVersion; return false; } + // F0V3 is untested, but since summary and events seem to be the same for F0V2 and F0V3, + // we'll assume this one is for now, but flag it as unexpected. + CHECK_VALUE(this->familyVersion, 2); + + // TODO: hardcoding this is ugly, think of a better approach + if (this->m_data.size() < 0x13) { + qWarning() << this->sessionid << "compliance data too short:" << this->m_data.size(); + return false; + } + const unsigned char * data = (unsigned char *)this->m_data.constData(); + + CHECK_VALUE(data[0x00], 0); + if (data[0x00] != 0) { + if (data[0x00] != 5) { + qDebug() << this->sessionid << "compliance first byte" << data[0x00] <<" != 0, skipping"; + } + return false; + } + CHECK_VALUES(data[0x01], 1, 0); // usually 1, occasionally 0, no visible difference in report + CHECK_VALUE(data[0x02], 0); this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_CPAP_MODE, (int) MODE_CPAP)); int min_pressure = data[0x03]; // EventDataType max_pressure = EventDataType(data[0x04]) / 10.0; + CHECK_VALUE(data[0x04], 0); + CHECK_VALUE(data[0x05], 0); this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE, min_pressure)); - int ramp_time = data[0x06]; int ramp_pressure = data[0x07]; - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_RAMP_TIME, ramp_time)); this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_RAMP_PRESSURE, ramp_pressure)); - - quint8 flex = data[0x09]; + quint8 flex = data[0x08]; // TODO: why was this 0x09 originally? could the position vary? this->ParseFlexSetting(flex, MODE_CPAP); - int humid = data[0x0A]; - this->ParseHumidifierSetting(humid, false); + int humid = data[0x09]; // TODO: why was this 0x0A originally? could the position vary? + this->ParseHumidifierSettingV2(humid, false); - // TODO: What are slices, and why would only bricks have them? That seems very weird. - // TODO: The below seems not to work on 200X models. + // TODO: Where is Auto Off/On set? (both off) + // TODO: Where is "Altitude Compensation" set? (seems to be 1) + // TODO: Where are Mask Alert/Reminder Period set? (both off) + CHECK_VALUE(data[0x0a], 0x80); + CHECK_VALUE(data[0x0b], 1); + CHECK_VALUE(data[0x0c], 0); + CHECK_VALUE(data[0x0d], 0); - // need to parse a repeating structure here containing lengths of mask on/off.. - // 0x03 = mask on - // 0x01 = mask off - + // List of slices, really session-related events: int start = 0; int tt = start; int len = this->size()-3; - int pos = 0x11; + int pos = 0x0e; do { quint8 c = data[pos++]; - int duration = data[pos] | data[pos+1] << 8; + // These aren't really slices as originally thought, they're events with a delta offset. + // We'll convert them to slices in the importer. + int delta = data[pos] | data[pos+1] << 8; pos+=2; SliceStatus status; - if (c == 0x03) { - status = EquipmentOn; - } else if (c == 0x02) { - status = EquipmentLeaking; + if (c == 0x02) { + status = MaskOn; + if (tt == 0) { + CHECK_VALUE(delta, 0); // we've never seen the initial MaskOn have any delta + } else { + if (delta % 60) UNEXPECTED_VALUE(delta, "even minutes"); // mask-off events seem to be whole minutes? + } + } else if (c == 0x03) { + status = MaskOff; } else if (c == 0x01) { status = EquipmentOff; + // This has a delta if the mask was removed before the machine was shut off. } else { - qDebug() << this->sessionid << "Wasn't expecting" << c; + qDebug() << this->sessionid << "unknown slice status" << c; break; } - this->AddEvent(new PRS1ParsedSliceEvent(tt, duration, status)); - - tt += duration; + tt += delta; + this->AddEvent(new PRS1ParsedSliceEvent(tt, status)); } while (pos < len); + // also seems to be a trailing 01 00 81 after the slices? + if (pos == len) { + CHECK_VALUES(data[pos], 1, 0); // usually 1, occasionally 0, no visible difference in report + //CHECK_VALUE(data[pos+1], 0); // sometimes 1, 2, or 5, no visible difference in report + //CHECK_VALUES(data[pos+2], 0x81, 0x80); // seems to be humidifier setting at end of session + if (data[pos+2] && (((data[pos+2] & 0x80) == 0) || (data[pos+2] & 0x07) > 5)) { + UNEXPECTED_VALUE(data[pos+2], "valid humidifier setting"); + } + } else { + qWarning() << this->sessionid << (this->size() - pos) << "trailing bytes"; + } + this->duration = tt; - // Bleh!! There is probably 10 different formats for these useless piece of junk machines return true; } bool PRS1DataChunk::ParseSummaryF0V23() { + if (this->family != 0 || (this->familyVersion != 2 && this->familyVersion != 3)) { + qWarning() << "ParseSummaryF0V23 called with family" << this->family << "familyVersion" << this->familyVersion; + return false; + } + // TODO: hardcoding this is ugly, think of a better approach + if (this->m_data.size() < 59) { + qWarning() << this->sessionid << "summary data too short:" << this->m_data.size(); + return false; + } const unsigned char * data = (unsigned char *)this->m_data.constData(); + CHECK_VALUE(data[0x00], 0); + if (data[0x00] != 0) { + if (data[0x00] != 5) { + qDebug() << this->sessionid << "summary first byte" << data[0x00] <<" != 0, skipping"; + } + return false; + } + CHECK_VALUES(data[0x01] & 0xF0, 0x60, 0x70); // TODO: what are these? + if ((data[0x01] & 0x0F) != 1) { // This is the most frequent value. + CHECK_VALUES(data[0x01] & 0x0F, 3, 0); // TODO: what are these? 0 seems to be related to errors. + } + CPAPMode cpapmode = MODE_UNKNOWN; switch (data[0x02]) { // PRS1 mode // 0 = CPAP, 2 = APAP @@ -3304,60 +3393,126 @@ bool PRS1DataChunk::ParseSummaryF0V23() break; case 0x03: cpapmode = MODE_BILEVEL_AUTO_VARIABLE_PS; + break; + default: + qWarning() << this->sessionid << "unknown cpap mode" << data[0x02]; + return false; } + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_CPAP_MODE, (int) cpapmode)); + int min_pressure = data[0x03]; int max_pressure = data[0x04]; - int ps = data[0x05]; // pressure support + int ps = data[0x05]; // max pressure support (for variable), seems to be zero otherwise if (cpapmode == MODE_CPAP) { this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE, min_pressure)); + CHECK_VALUE(max_pressure, 0); + CHECK_VALUE(ps, 0); } else if (cpapmode == MODE_APAP) { this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE_MIN, min_pressure)); this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE_MAX, max_pressure)); + CHECK_VALUE(ps, 0); } else if (cpapmode == MODE_BILEVEL_FIXED) { this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_EPAP, min_pressure)); this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_IPAP, max_pressure)); - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS, ps)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS, max_pressure - min_pressure)); + CHECK_VALUE(ps, 0); // this seems to be unused on fixed bilevel } else if (cpapmode == MODE_BILEVEL_AUTO_VARIABLE_PS) { int min_ps = 20; // 2.0 cmH2O this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_EPAP_MIN, min_pressure)); - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_EPAP_MAX, max_pressure - min_ps)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_EPAP_MAX, max_pressure - min_ps)); // TODO: not yet confirmed this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_IPAP_MIN, min_pressure + min_ps)); this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_IPAP_MAX, max_pressure)); this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS_MIN, min_ps)); this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS_MAX, ps)); } - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_CPAP_MODE, (int) cpapmode)); - - int ramp_time = data[0x06]; int ramp_pressure = data[0x07]; - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_RAMP_TIME, ramp_time)); this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_RAMP_PRESSURE, ramp_pressure)); - // Tubing lock has no setting byte - - // Menu Options - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_SYSTEMONE_RESIST_LOCK, (data[0x0a] & 0x80) != 0)); // System One Resistance Lock Setting - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_SYSTEMONE_RESIST_SETTING, data[0x0a] & 7)); // SYstem One Resistance setting value - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_SYSTEMONE_RESIST_STATUS, (data[0x0a] & 0x40) != 0)); // System One Resistance Status bit - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_HOSE_DIAMETER, (data[0x0a] & 0x08) ? 15 : 22)); - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_AUTO_ON, (data[0x0b] & 0x40) != 0)); - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_AUTO_OFF, (data[0x0c] & 0x10) != 0)); - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_MASK_ALERT, (data[0x0c] & 0x08) != 0)); - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_SHOW_AHI, (data[0x0c] & 0x04) != 0)); - int humid = data[0x09]; - this->ParseHumidifierSetting(humid, false); - - // session-> - quint8 flex = data[0x08]; this->ParseFlexSetting(flex, cpapmode); - this->duration = data[0x14] | data[0x15] << 8; + int humid = data[0x09]; + this->ParseHumidifierSettingV2(humid, false); + + // Tubing lock has no setting byte + + // Menu Options + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_SYSTEMONE_RESIST_LOCK, (data[0x0a] & 0x80) != 0)); // System One Resistance Lock Setting + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_SYSTEMONE_RESIST_STATUS, (data[0x0a] & 0x40) != 0)); // System One Resistance Status bit + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_HOSE_DIAMETER, (data[0x0a] & 0x08) ? 15 : 22)); // TODO: unconfirmed + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_SYSTEMONE_RESIST_SETTING, data[0x0a] & 7)); // System One Resistance setting value + CHECK_VALUE(data[0x0a] & (0x20 | 0x10), 0); + + CHECK_VALUE(data[0x0b], 1); + + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_AUTO_ON, (data[0x0c] & 0x40) != 0)); + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_AUTO_OFF, (data[0x0c] & 0x10) != 0)); + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_MASK_ALERT, (data[0x0c] & 0x04) != 0)); + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_SHOW_AHI, (data[0x0c] & 0x02) != 0)); + CHECK_VALUE(data[0x0c] & (0xA0 | 0x09), 0); + + CHECK_VALUE(data[0x0d], 0); + //CHECK_VALUES(data[0x0e], ramp_pressure, min_pressure); // initial CPAP/EPAP, can be minimum pressure or ramp, or whatever auto decides to use + if (cpapmode == MODE_BILEVEL_FIXED) { // initial IPAP for bilevel modes + CHECK_VALUE(data[0x0f], max_pressure); + } else if (cpapmode == MODE_BILEVEL_AUTO_VARIABLE_PS) { + CHECK_VALUE(data[0x0f], min_pressure + 20); + } + + // List of slices, really session-related events: + int start = 0; + int tt = start; + + int len = this->size()-3; + int pos = 0x10; + do { + quint8 c = data[pos++]; + int delta = data[pos] | data[pos+1] << 8; + pos+=2; + SliceStatus status; + if (c == 0x02) { + status = MaskOn; + if (tt == 0) { + CHECK_VALUE(delta, 0); // we've never seen the initial MaskOn have any delta + } else { + if (delta % 60) UNEXPECTED_VALUE(delta, "even minutes"); // mask-off events seem to be whole minutes? + } + } else if (c == 0x03) { + status = MaskOff; + // These are 0x22 bytes in a summary vs. 3 bytes in compliance data + // TODO: What are these values? + pos += 0x1F; + } else if (c == 0x01) { + status = EquipmentOff; + // This has a delta if the mask was removed before the machine was shut off. + } else { + qDebug() << this->sessionid << "unknown slice status" << c; + break; + } + tt += delta; + this->AddEvent(new PRS1ParsedSliceEvent(tt, status)); + } while (pos < len); + + // seems to be trailing 01 [01 or 02] 83 after the slices? + if (pos == len) { + if (data[pos] != 1) { // This is the usual value. + CHECK_VALUES(data[pos], 0, 3); // 0 seems to be related to errors, 3 seen after 90 sec large leak before turning off? + } + //CHECK_VALUES(data[pos+1], 0, 1); // TODO: may be related to ramp? 1-5 seems to have a ramp start or two + //CHECK_VALUES(data[pos+2], 0x81, 0x80); // seems to be humidifier setting at end of session + if (data[pos+2] && (((data[pos+2] & 0x80) == 0) || (data[pos+2] & 0x07) > 5)) { + UNEXPECTED_VALUE(data[pos+2], "valid humidifier setting"); + } + } else { + qWarning() << this->sessionid << (this->size() - pos) << "trailing bytes"; + } + + this->duration = tt; return true; } @@ -3417,7 +3572,7 @@ bool PRS1DataChunk::ParseSummaryF0V4(void) this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_RAMP_PRESSURE, ramp_pressure)); int humid = data[0x0b]; - this->ParseHumidifierSetting(humid); + this->ParseHumidifierSettingV2(humid); this->duration = data[0x14] | data[0x15] << 8; @@ -3464,7 +3619,7 @@ bool PRS1DataChunk::ParseSummaryF3(void) if ((it=this->hbdata.find(5)) != this->hbdata.end()) { this->duration = (it.value()[1] << 8 ) + it.value()[0]; } else { - qWarning() << "missing summary duration"; + qWarning() << this->sessionid << "missing summary duration"; } return true; @@ -3503,7 +3658,7 @@ bool PRS1DataChunk::ParseSummaryF5V012(void) this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_RAMP_PRESSURE, ramp_pressure)); int humid = data[0x0d]; - this->ParseHumidifierSetting(humid); + this->ParseHumidifierSettingV2(humid); this->duration = data[0x18] | data[0x19] << 8; @@ -3522,6 +3677,8 @@ void PRS1DataChunk::ParseFlexSetting(quint8 flex, CPAPMode cpapmode) // c0 Split CFlex then None // c8 Split CFlex+ then None + if (flex & (0x20 | 0x04)) UNEXPECTED_VALUE(flex, "known bits"); + flex &= 0xf8; bool split = false; @@ -3548,13 +3705,21 @@ void PRS1DataChunk::ParseFlexSetting(quint8 flex, CPAPMode cpapmode) } -void PRS1DataChunk::ParseHumidifierSetting(int humid, bool supportsHeatedTubing) +void PRS1DataChunk::ParseHumidifierSettingV2(int humid, bool supportsHeatedTubing) { + if (humid & (0x40 | 0x08)) UNEXPECTED_VALUE(humid, "known bits"); + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_HUMID_STATUS, (humid & 0x80) != 0)); // Humidifier Connected if (supportsHeatedTubing) { this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_HEATED_TUBING, (humid & 0x10) != 0)); // Heated Hose?? + // TODO: 0x20 is seen on machines with System One humidification & heated tubing, not sure which setting it represents. + } else { + CHECK_VALUE(humid & 0x30, 0); } - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_HUMID_LEVEL, (humid & 7))); // Humidifier Value + int humidlevel = humid & 7; + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_HUMID_LEVEL, humidlevel)); // Humidifier Value + + if (humidlevel > 5) UNEXPECTED_VALUE(humidlevel, "<= 5"); } @@ -3590,168 +3755,555 @@ bool PRS1DataChunk::ParseSummaryF5V3(void) } -bool PRS1DataChunk::ParseSummaryF0V6() +// The below is based on fixing the fileVersion == 3 parsing in ParseSummary() based +// on our understanding of slices from F0V23. The switch values come from sample files. +bool PRS1DataChunk::ParseComplianceF0V6(void) { - // DreamStation machines... - - // APAP models.. - + if (this->family != 0 || this->familyVersion != 6) { + qWarning() << "ParseComplianceF0V6 called with family" << this->family << "familyVersion" << this->familyVersion; + return false; + } + // TODO: hardcoding this is ugly, think of a better approach + if (this->m_data.size() < 82) { + qWarning() << this->sessionid << "compliance data too short:" << this->m_data.size(); + return false; + } const unsigned char * data = (unsigned char *)this->m_data.constData(); + int chunk_size = this->m_data.size(); + static const int expected_sizes[] = { 1, 0x34, 9, 4, 2, 2, 4, 8 }; + static const int ncodes = sizeof(expected_sizes) / sizeof(int); + for (int i = 0; i < ncodes; i++) { + if (this->hblock.contains(i)) { + CHECK_VALUE(this->hblock[i], expected_sizes[i]); + } else { + UNEXPECTED_VALUE(this->hblock.contains(i), true); + } + } + + bool ok = true; + int pos = 0; + int code, size; + int tt = 0; + do { + code = data[pos++]; + if (!this->hblock.contains(code)) { + qWarning() << this->sessionid << "missing hblock entry for" << code; + ok = false; + break; + } + size = this->hblock[code]; + if (size < expected_sizes[code]) { + qWarning() << this->sessionid << "slice" << code << "too small" << size << "<" << expected_sizes[code]; + ok = false; + break; + } + if (pos + size > chunk_size) { + qWarning() << this->sessionid << "slice" << code << "@" << pos << "longer than remaining chunk"; + ok = false; + break; + } + + switch (code) { + case 0: + // always first? Maybe equipmenton? Maybe 0 was always equipmenton, even in F0V23? + CHECK_VALUE(pos, 1); + //CHECK_VALUES(data[pos], 1, 3); // sometimes 7? + break; + case 1: // Settings + // This is where ParseSummaryF0V6 started (after "3 bytes that don't follow the pattern") + // Both compliance and summary files seem to have the same length for this slice, so maybe the + // settings are the same? + ok = this->ParseSettingsF0V6(data + pos, size); + break; + case 3: // Mask On + tt += data[pos] | (data[pos+1] << 8); + this->AddEvent(new PRS1ParsedSliceEvent(tt, MaskOn)); + this->ParseHumidifierSettingF0V6(data[pos+2], data[pos+3]); + break; + case 4: // Mask Off + tt += data[pos] | (data[pos+1] << 8); + this->AddEvent(new PRS1ParsedSliceEvent(tt, MaskOff)); + break; + case 7: + // Always follows mask off? + //CHECK_VALUES(data[pos], 0x01, 0x00); // sometimes 32, 4 + CHECK_VALUE(data[pos+1], 0x00); + //CHECK_VALUES(data[pos+2], 0x00, 0x01); // sometimes 11, 3, 15 + CHECK_VALUE(data[pos+3], 0x00); + //CHECK_VALUE(data[pos+4], 0x05, 0x0A); // 00 + CHECK_VALUE(data[pos+5], 0x00); + //CHECK_VALUE(data[pos+6], 0x64, 0x69); // 6E, 6D, 6E, 6E, 80 + //CHECK_VALUE(data[pos+7], 0x3d, 0x5c); // 6A, 6A, 6B, 6C, 80 + break; + case 2: // Equipment Off + tt += data[pos] | (data[pos+1] << 8); + this->AddEvent(new PRS1ParsedSliceEvent(tt, EquipmentOff)); + //CHECK_VALUE(data[pos+2], 0x08); // 0x01 + //CHECK_VALUE(data[pos+3], 0x14); // 0x12 + //CHECK_VALUE(data[pos+4], 0x01); // 0x00 + //CHECK_VALUE(data[pos+5], 0x22); // 0x28 + //CHECK_VALUE(data[pos+6], 0x02); // sometimes 1, 0 + CHECK_VALUE(data[pos+7], 0x00); // 0x00 + CHECK_VALUE(data[pos+8], 0x00); // 0x00 + break; + case 6: // Humidier setting change + tt += data[pos] | (data[pos+1] << 8); // This adds to the total duration (otherwise it won't match report) + this->ParseHumidifierSettingF0V6(data[pos+2], data[pos+3]); + break; + default: + UNEXPECTED_VALUE(code, "known slice code"); + break; + } + pos += size; + } while (ok && pos < chunk_size); + + this->duration = tt; + + return ok; +} + + +void PRS1DataChunk::ParseHumidifierSettingF0V6(unsigned char byte1, unsigned char byte2, bool add_setting) +{ + // Byte 1: 0x90 (no humidifier data), 0x50 (15ht, tube 4/5, humid 4), 0x54 (15ht, tube 5, humid 5) 0x4c (15ht, tube temp 3, humidifier 3) + // 0x0c (15, tube 3, humid 3, fixed) + // 0b10010000 no humidifier data + // 0b01010000 tube 4 and 5, humidifier 4 + // 0b01010100 15ht, tube 5, humidifier 5 + // 0b01001100 15ht, tube 3, humidifier 3 + // xxx = humidifier setting + // xxx = humidifier status + // ?? + CHECK_VALUE(byte1 & 3, 0); + int humid = byte1 >> 5; + switch (humid) { + case 0: break; // fixed + case 1: break; // adaptive + case 2: break; // heated tube + case 4: break; // no humidifier, possibly a bit flag rather than integer value + default: + UNEXPECTED_VALUE(humid, "known value"); + break; + } + bool humidifier_present = ((byte1 & 0x80) == 0); + int humidlevel = (byte1 >> 2) & 7; + + // Byte 2: 0xB4 (15ht, tube 5, humid 5), 0xB0 (15ht, tube 5, humid 4), 0x90 (tube 4, humid 4), 0x6C (15ht, tube temp 3, humidifier 3) + // 0x80? + // 0b10110100 15ht, tube 5, humidifier 5 + // 0b10110000 15ht, tube 5, humidifier 4 + // 0b10010000 tube 4, humidifier 4 + // 0b01101100 15ht, tube 3, humidifier 3 + // xxx = humidifier setting + // xxx = tube setting + // ?? + CHECK_VALUE(byte2 & 3, 0); + CHECK_VALUE(humidlevel, ((byte2 >> 2) & 7)); + int tubelevel = (byte2 >> 5) & 7; + if (humidifier_present) { + if (humidlevel > 5 || humidlevel < 0) UNEXPECTED_VALUE(humidlevel, "0-5"); // 0=off is valid when a humidifier is attached + if (humid == 2) { // heated tube + if (tubelevel > 5 || tubelevel < 0) UNEXPECTED_VALUE(tubelevel, "0-5"); // TODO: maybe this is only if heated tube? 0=off is valid even in heated tube mode + } + } + + if (add_setting) { + //this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_HUMID_STATUS, (humid & 0x80) != 0)); // this is F0V23 version, doesn't match F0V6 + //this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_HEATED_TUBING, (humid & 0x10) != 0)); // this is F0V23 version, doesn't match F0V6 + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_HUMID_LEVEL, humidlevel)); + + // TODO: add a channel for PRS1 heated tubing + //this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_TUBE_LEVEL, tubelevel)); + } +} + + +// The below is based on a combination of the mainblock parsing for fileVersion == 3 +// in ParseSummary() and the switch statements of ParseSummaryF0V6. +// +// Both compliance and summary files (at least for 200X and 400X machines) seem to have +// the same length for this slice, so maybe the settings are the same? At least 0x0a +// looks like a pressure in compliance files. +bool PRS1DataChunk::ParseSettingsF0V6(const unsigned char* data, int size) +{ + static const QMap<int,int> expected_lengths = { {0x0c,3}, {0x0d,2}, {0x0e,2}, {0x0f,4}, {0x10,3}, {0x35,2} }; + bool ok = true; CPAPMode cpapmode = MODE_UNKNOWN; - int imin_epap = 0; - //int imax_epap = 0; + int pressure = 0; int imin_ps = 0; int imax_ps = 0; - //int imax_pressure = 0; int min_pressure = 0; int max_pressure = 0; - int duration = 0; - // in 'data', we start with 3 bytes that don't follow the pattern - // pattern is varNumber, dataSize, dataValue(dataSize) - // examples, 0x0d 0x02 0x28 0xC8 , or 0x0a 0x01 0x64, - // first, verify that this dataSize is where we expect - // each var pair in headerblock should be (indexByte, valueByte) + // Parse the nested data structure which contains settings + int pos = 0; + do { + int code = data[pos++]; + int len = data[pos++]; - if ((int)this->m_headerblock[(1 * 2)] != 0x01) { - return false; //nope, not here - qDebug() << "PRS1DataChunk::ParseSummaryF0V6=" << "Bad datablock length"; - } - int dataBlockSize = this->m_headerblock[(1 * 2) + 1]; - //int zero = 0; - const unsigned char *dataPtr; - - // start at 3rd byte ; did we go past the end? ; increment for dataSize + varNumberByte + dataSizeByte - for ( dataPtr = data + 3; dataPtr < (data + 3 + dataBlockSize); dataPtr+= dataPtr[1] + 2) { - switch( *dataPtr) { - case 00: // mode? - break; - case 01: // ??? - break; - case 10: // 0x0a - cpapmode = MODE_CPAP; - if (dataPtr[1] != 1) qDebug() << "PRS1DataChunk::ParseSummaryF0V6=" << "Bad CPAP value"; - imin_epap = dataPtr[2]; - break; - case 13: // 0x0d - cpapmode = MODE_APAP; - if (dataPtr[1] != 2) qDebug() << "PRS1DataChunk::ParseSummaryF0V6=" << "Bad APAP value"; - min_pressure = dataPtr[2]; - max_pressure = dataPtr[3]; - break; - case 14: // 0x0e // <--- this is a total guess.. might be 3 and have a pressure support value - cpapmode = MODE_BILEVEL_FIXED; - if (dataPtr[1] != 2) qDebug() << "PRS1DataChunk::ParseSummaryF0V6=" << "Bad APAP value"; - min_pressure = dataPtr[2]; - max_pressure = dataPtr[3]; - imin_ps = max_pressure - min_pressure; - break; - case 15: // 0x0f - cpapmode = MODE_BILEVEL_AUTO_VARIABLE_PS; //might be C_CHECK? - if (dataPtr[1] != 4) qDebug() << "PRS1DataChunk::ParseSummaryF0V6=" << "Bad APAP value"; - min_pressure = dataPtr[2]; - max_pressure = dataPtr[3]; - imin_ps = dataPtr[4]; - imax_ps = dataPtr[5]; - break; - case 0x10: // Auto Trial mode - cpapmode = MODE_APAP; - if (dataPtr[1] != 3) qDebug() << "PRS1DataChunk::ParseSummaryF0V6=" << "Bad APAP value"; - min_pressure = dataPtr[3]; - max_pressure = dataPtr[4]; - break; - - case 0x35: - duration += ( dataPtr[3] << 8 ) + dataPtr[2]; - break; -// case 3: -// break; - default: - // have not found this before - ; - // qDebug() << "PRS1Loader::ParseSummaryF0V6=" << "Unknown datablock value:" << (zero + *dataPtr) ; + int expected_len = 1; + if (expected_lengths.contains(code)) { + expected_len = expected_lengths[code]; } - } - // now we encounter yet a different format of data - /* const unsigned char *data2Ptr = data + 3 + dataBlockSize; - // pattern is byte/data, where length of data depends on value of 'byte' - bool data2Done = false; - while (!data2Done) { - switch(*data2Ptr){ - case 0: - //this appears to be the last one. '0' plus 5 bytes **eats crc** without checking - data2Ptr += 4; - data2Ptr += 2; //this is the **CRC**?? - data2Done = true; //hope this is always there, since we don't have blocksize from header - break; - case 1: - //don't know yet. data size is the '1' plus 16 bytes - data2Ptr += 5; - break; - case 2: - //don't know yet. data size is the '2' plus 16 bytes - data2Ptr += 3; - break; - case 3: - //don't know yet. data size is the '3' plus 4 bytes - // have seen multiple of these....may have to add them? - data2Ptr += 5; - break; - case 4: - // have seen multiple of these....may have to add them? - duration = ( data2Ptr[3] << 8 ) + data2Ptr[2]; - data2Ptr += 3; - break; - case 5: - //don't know yet. data size is the '5' plus 4 bytes - data2Ptr += 5; - break; - case 6: - //don't know yet. data size is the '5' plus 1 byte - data2Ptr += 2; - break; - case 8: - //don't know yet. data size is the '8' plus 27 bytes (might be a '0' in here...not enough different types found yet) - data2Ptr += 28; - break; - default: - qDebug() << "PRS1Loader::ParseSummaryF0V6=" << "Unknown datablock2 value:" << (zero + *data2Ptr) ; + //CHECK_VALUE(len, expected_len); + if (len < expected_len) { + qWarning() << this->sessionid << "setting" << code << "too small" << len << "<" << expected_len; + ok = false; + break; + } + if (pos + len > size) { + qWarning() << this->sessionid << "setting" << code << "@" << pos << "longer than remaining slice"; + ok = false; break; } - }*/ -// need to populate summary-> - this->duration = duration; - this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_CPAP_MODE, (int) cpapmode)); - if (cpapmode == MODE_CPAP) { - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE, imin_epap)); + switch (code) { + case 0: // Device Mode + CHECK_VALUE(pos, 2); // always first? + switch (data[pos]) { + case 0: cpapmode = MODE_CPAP; break; + case 2: cpapmode = MODE_APAP; break; + case 1: cpapmode = MODE_BILEVEL_FIXED; break; + case 3: cpapmode = MODE_BILEVEL_AUTO_VARIABLE_PS; break; + case 4: cpapmode = MODE_CPAP; break; // "CPAP-Check" in report, but seems like CPAP + default: + UNEXPECTED_VALUE(data[pos], "known device mode"); + break; + } + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_CPAP_MODE, (int) cpapmode)); + break; + case 1: // ??? + if (data[pos] != 0 && data[pos] != 3) { + CHECK_VALUES(data[pos], 1, 2); // 1 when EZ-Start is enabled? 2 when Auto-Trial? 3 when Auto-Trial is off or Opti-Start isn't off? + } + if (len == 2) { // 400G has extra byte + CHECK_VALUE(data[pos+1], 0); + } + break; + case 0x0a: // CPAP pressure setting + CHECK_VALUE(cpapmode, MODE_CPAP); + pressure = data[pos]; + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE, pressure)); + break; + case 0x0c: // CPAP-Check pressure setting + CHECK_VALUE(cpapmode, MODE_CPAP); + min_pressure = data[pos]; // Min Setting on pressure graph + max_pressure = data[pos+1]; // Max Setting on pressure graph + pressure = data[pos+2]; // CPAP on pressure graph and CPAP-Check Pressure on settings detail + // This seems to be the initial pressure. If the pressure changes mid-session, the pressure + // graph will show either the changed pressure or the majority pressure, not sure which. + // The time of change is most likely in the events file. See slice 6 for ending pressure. + //CHECK_VALUE(pressure, 0x5a); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE, pressure)); + break; + case 0x0d: // AutoCPAP pressure setting + CHECK_VALUE(cpapmode, MODE_APAP); + min_pressure = data[pos]; + max_pressure = data[pos+1]; + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE_MIN, min_pressure)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE_MAX, max_pressure)); + break; + case 0x0e: // Bi-Level pressure setting + CHECK_VALUE(cpapmode, MODE_BILEVEL_FIXED); + min_pressure = data[pos]; + max_pressure = data[pos+1]; + imin_ps = max_pressure - min_pressure; + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_EPAP, min_pressure)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_IPAP, max_pressure)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS, imin_ps)); + break; + case 0x0f: // Auto Bi-Level pressure setting + CHECK_VALUE(cpapmode, MODE_BILEVEL_AUTO_VARIABLE_PS); + min_pressure = data[pos]; + max_pressure = data[pos+1]; + imin_ps = data[pos+2]; + imax_ps = data[pos+3]; + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_EPAP_MIN, min_pressure)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_IPAP_MAX, max_pressure)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS_MIN, imin_ps)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS_MAX, imax_ps)); + break; + case 0x10: // Auto-Trial mode + CHECK_VALUE(cpapmode, MODE_CPAP); // the mode setting is CPAP, even though it's operating in APAP mode + cpapmode = MODE_APAP; // but categorize it now as APAP, since that's what it's really doing + CHECK_VALUES(data[pos], 30, 5); // Auto-Trial Duration + min_pressure = data[pos+1]; + max_pressure = data[pos+2]; + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_CPAP_MODE, (int) cpapmode)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE_MIN, min_pressure)); + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE_MAX, max_pressure)); + break; + case 0x2a: // EZ-Start + CHECK_VALUE(data[pos], 0x80); // EZ-Start enabled + break; + case 0x2b: // Ramp Type + CHECK_VALUES(data[pos], 0, 0x80); // 0 == "Linear", 0x80 = "SmartRamp" + break; + case 0x2c: // Ramp Time + if (data[pos] != 0) { // 0 == ramp off, and ramp pressure setting doesn't appear + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_RAMP_TIME, data[pos])); + } + break; + case 0x2d: // Ramp Pressure + this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_RAMP_PRESSURE, data[pos])); + break; + case 0x2e: + if (data[pos] != 0) { + CHECK_VALUES(data[pos], 0x80, 0x90); // maybe flex related? 0x80 when c-flex? 0x90 when c-flex+ or A-flex?, 0x00 when no flex + } + break; + case 0x2f: // Flex lock + CHECK_VALUES(data[pos], 0, 0x80); + break; + case 0x30: // Flex level + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_FLEX_LEVEL, data[pos])); + break; + case 0x35: // Humidifier setting + this->ParseHumidifierSettingF0V6(data[pos], data[pos+1], true); + break; + case 0x36: + CHECK_VALUE(data[pos], 0); + break; + case 0x38: // Mask Resistance + if (data[pos] != 0) { // 0 == mask resistance off + this->AddEvent(new PRS1ParsedSettingEvent(PRS1_SETTING_SYSTEMONE_RESIST_SETTING, data[pos])); + } + break; + case 0x39: + CHECK_VALUES(data[pos], 0, 0x80); // 0x80 maybe auto-trial? + break; + case 0x3b: + if (data[pos] != 0) { + CHECK_VALUES(data[pos], 2, 1); // tubing type? 15HT = 2, 15 = 1, 22 = 0? + } + break; + case 0x40: // new to 400G, also seen on 500X110, alternate tubing type? appears after 0x39 and before 0x3c + if (data[pos] != 3) { + CHECK_VALUES(data[pos], 1, 2); // 1 = 15mm, 2 = 15HT, 3 = 12mm + } + break; + case 0x3c: + CHECK_VALUES(data[pos], 0, 0x80); // 0x80 maybe show AHI? + break; + case 0x3e: + CHECK_VALUES(data[pos], 0, 0x80); // 0x80 maybe auto-on? + break; + case 0x3f: + CHECK_VALUES(data[pos], 0, 0x80); // 0x80 maybe auto-off? + break; + case 0x43: // new to 502G, sessions 3-8, Auto-Trial is off, Opti-Start is missing + CHECK_VALUE(data[pos], 0x3C); + break; + case 0x44: // new to 502G, sessions 3-8, Auto-Trial is off, Opti-Start is missing + CHECK_VALUE(data[pos], 0xFF); + break; + case 0x45: // new to 400G, only in last session? + CHECK_VALUE(data[pos], 1); + break; + default: + qDebug() << "Unknown setting:" << hex << code << "in" << this->sessionid << "at" << pos; + this->AddEvent(new PRS1UnknownDataEvent(QByteArray((const char*) data, size), pos, len)); + break; + } - } else if (cpapmode == MODE_APAP) { - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE_MIN, min_pressure)); - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PRESSURE_MAX, max_pressure)); - } else if (cpapmode == MODE_BILEVEL_FIXED) { - // Guessing here.. haven't seen BIPAP data. - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_EPAP, min_pressure)); - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_IPAP, max_pressure)); - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS, imin_ps)); - } else if (cpapmode == MODE_BILEVEL_AUTO_VARIABLE_PS) { - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_EPAP_MIN, min_pressure)); - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_IPAP_MAX, max_pressure)); - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS_MIN, imin_ps)); - this->AddEvent(new PRS1PressureSettingEvent(PRS1_SETTING_PS_MAX, imax_ps)); + pos += len; + } while (ok && pos + 2 <= size); + + return ok; +} + + +bool PRS1DataChunk::ParseSummaryF0V6(void) +{ + if (this->family != 0 || this->familyVersion != 6) { + qWarning() << "ParseSummaryF0V6 called with family" << this->family << "familyVersion" << this->familyVersion; + return false; } + const unsigned char * data = (unsigned char *)this->m_data.constData(); + int chunk_size = this->m_data.size(); + static const int minimum_sizes[] = { 1, 0x2b, 9, 4, 2, 4, 1, 4, 0x1b, 2, 4, 0x0b, 1, 2, 6 }; + static const int ncodes = sizeof(minimum_sizes) / sizeof(int); + // NOTE: The sizes contained in hblock can vary, even within a single machine, as can the length of hblock itself! - return true; + // TODO: hardcoding this is ugly, think of a better approach + if (chunk_size < minimum_sizes[0] + minimum_sizes[1] + minimum_sizes[2]) { + qWarning() << this->sessionid << "summary data too short:" << chunk_size; + return false; + } + if (chunk_size < 60) UNEXPECTED_VALUE(chunk_size, ">= 60"); + + bool ok = true; + int pos = 0; + int code, size; + int tt = 0; + do { + code = data[pos++]; + if (!this->hblock.contains(code)) { + qWarning() << this->sessionid << "missing hblock entry for" << code; + ok = false; + break; + } + size = this->hblock[code]; + if (code < ncodes) { + // make sure the handlers below don't go past the end of the buffer + if (size < minimum_sizes[code]) { + qWarning() << this->sessionid << "slice" << code << "too small" << size << "<" << minimum_sizes[code]; + ok = false; + break; + } + } // else if it's past ncodes, we'll log its information below (rather than handle it) + if (pos + size > chunk_size) { + qWarning() << this->sessionid << "slice" << code << "@" << pos << "longer than remaining chunk"; + ok = false; + break; + } + + switch (code) { + case 0: // Equipment On + CHECK_VALUE(pos, 1); // Always first? + //CHECK_VALUES(data[pos], 1, 7); // or 3? + if (size == 4) { // 400G has 3 more bytes? + //CHECK_VALUE(data[pos+1], 0); // or 2, 14, 4, etc. + //CHECK_VALUES(data[pos+2], 8, 65); // or 1 + //CHECK_VALUES(data[pos+3], 0, 20); // or 21, 22, etc. + } + break; + case 1: // Settings + ok = this->ParseSettingsF0V6(data + pos, size); + break; + case 3: // Mask On + tt += data[pos] | (data[pos+1] << 8); + this->AddEvent(new PRS1ParsedSliceEvent(tt, MaskOn)); + this->ParseHumidifierSettingF0V6(data[pos+2], data[pos+3]); + break; + case 4: // Mask Off + tt += data[pos] | (data[pos+1] << 8); + this->AddEvent(new PRS1ParsedSliceEvent(tt, MaskOff)); + break; + case 8: // vs. 7 in compliance, always follows mask off (except when there's a 5, see below), also longer + // Maybe statistics of some kind, given the pressure stats that seem to appear before it on AutoCPAP machines? + //CHECK_VALUES(data[pos], 0x02, 0x01); // probably 16-bit value + CHECK_VALUE(data[pos+1], 0x00); + //CHECK_VALUES(data[pos+2], 0x0d, 0x0a); // probably 16-bit value, maybe OA count? + CHECK_VALUE(data[pos+3], 0x00); + //CHECK_VALUES(data[pos+4], 0x09, 0x0b); // probably 16-bit value + CHECK_VALUE(data[pos+5], 0x00); + //CHECK_VALUES(data[pos+6], 0x1e, 0x35); // probably 16-bit value + CHECK_VALUE(data[pos+7], 0x00); + //CHECK_VALUES(data[pos+8], 0x8c, 0x4c); // 16-bit value, not sure what + //CHECK_VALUE(data[pos+9], 0x00); + //CHECK_VALUES(data[pos+0xa], 0xbb, 0x00); // 16-bit minutes in large leak + //CHECK_VALUE(data[pos+0xb], 0x00); + //CHECK_VALUES(data[pos+0xc], 0x15, 0x02); // probably 16-bit value + CHECK_VALUE(data[pos+0xd], 0x00); + //CHECK_VALUES(data[pos+0xe], 0x01, 0x00); // 16-bit VS count + //CHECK_VALUE(data[pos+0xf], 0x00); + //CHECK_VALUES(data[pos+0x10], 0x21, 5); // probably 16-bit value, maybe H count? + CHECK_VALUE(data[pos+0x11], 0x00); + //CHECK_VALUES(data[pos+0x12], 0x13, 0); // probably 16-bit value + CHECK_VALUE(data[pos+0x13], 0x00); + //CHECK_VALUES(data[pos+0x14], 0x05, 0); // probably 16-bit value, maybe RE count? + CHECK_VALUE(data[pos+0x15], 0x00); + //CHECK_VALUE(data[pos+0x16], 0x00, 4); // probably a 16-bit value, PB or FL count? + CHECK_VALUE(data[pos+0x17], 0x00); + //CHECK_VALUES(data[pos+0x18], 0x69, 0x23); + //CHECK_VALUES(data[pos+0x19], 0x44, 0x18); + //CHECK_VALUES(data[pos+0x1a], 0x80, 0x49); + if (size >= 0x1f) { // 500X is only 0x1b long! + //CHECK_VALUES(data[pos+0x1b], 0x00, 6); + CHECK_VALUE(data[pos+0x1c], 0x00); + //CHECK_VALUES(data[pos+0x1d], 0x0c, 0x0d); + //CHECK_VALUES(data[pos+0x1e], 0x31, 0x3b); + // TODO: 400G has 8 more bytes? + // TODO: 400G sometimes has another 4 on top of that? + } + break; + case 2: // Equipment Off + tt += data[pos] | (data[pos+1] << 8); + this->AddEvent(new PRS1ParsedSliceEvent(tt, EquipmentOff)); + //CHECK_VALUE(data[pos+2], 0x08); // 0x01 + //CHECK_VALUE(data[pos+3], 0x14); // 0x12 + //CHECK_VALUE(data[pos+4], 0x01); // 0x00 + //CHECK_VALUE(data[pos+5], 0x22); // 0x28 + //CHECK_VALUE(data[pos+6], 0x02); // sometimes 1, 0 + CHECK_VALUE(data[pos+7], 0x00); // 0x00 + CHECK_VALUE(data[pos+8], 0x00); // 0x00 + if (size == 0x0c) { // 400G has 3 more bytes, seem to match Equipment On bytes + //CHECK_VALUE(data[pos+1], 0); + //CHECK_VALUES(data[pos+2], 8, 65); + //CHECK_VALUE(data[pos+3], 0); + } + break; + case 0x0a: // Humidier setting change + tt += data[pos] | (data[pos+1] << 8); // This adds to the total duration (otherwise it won't match report) + this->ParseHumidifierSettingF0V6(data[pos+2], data[pos+3]); + break; + case 0x0e: + // only seen once on 400G? + CHECK_VALUE(data[pos], 0); + CHECK_VALUE(data[pos+1], 0); + CHECK_VALUE(data[pos+2], 7); + CHECK_VALUE(data[pos+3], 7); + CHECK_VALUE(data[pos+4], 7); + CHECK_VALUE(data[pos+5], 0); + break; + case 0x05: + // AutoCPAP-related? First appeared on 500X, follows 4, before 8, look like pressure values + //CHECK_VALUE(data[pos], 0x4b); // maybe min pressure? (matches ramp pressure, see ramp on pressure graph) + //CHECK_VALUE(data[pos+1], 0x5a); // maybe max pressure? (close to max on pressure graph, time at pressure graph) + //CHECK_VALUE(data[pos+2], 0x5a); // seems to match Average 90% Pressure + //CHECK_VALUE(data[pos+3], 0x58); // seems to match Average CPAP + break; + case 0x07: + // AutoBiLevel-related? First appeared on 700X, follows 4, before 8, looks like pressure values + //CHECK_VALUE(data[pos], 0x50); // maybe min IPAP or max titrated EPAP? (matches time at pressure graph, auto bi-level summary) + //CHECK_VALUE(data[pos+1], 0x64); // maybe max IPAP or max titrated IPAP? (matches time at pressure graph, auto bi-level summary) + //CHECK_VALUE(data[pos+2], 0x4b); // seems to match 90% EPAP + //CHECK_VALUE(data[pos+3], 0x64); // seems to match 90% IPAP + break; + case 0x0b: + // CPAP-Check related? follows 3 in CPAP-Check mode + tt += data[pos] | (data[pos+1] << 8); // This adds to the total duration (otherwise it won't match report) + //CHECK_VALUE(data[pos+2], 0); // probably 16-bit value + CHECK_VALUE(data[pos+3], 0); + //CHECK_VALUE(data[pos+4], 0); // probably 16-bit value + CHECK_VALUE(data[pos+5], 0); + //CHECK_VALUE(data[pos+6], 0); // probably 16-bit value + CHECK_VALUE(data[pos+7], 0); + //CHECK_VALUE(data[pos+8], 0); // probably 16-bit value + CHECK_VALUE(data[pos+9], 0); + //CHECK_VALUES(data[pos+0xa], 20, 60); // or 0? 44 when changed pressure mid-session? + break; + case 0x06: + // Maybe starting pressure? follows 4, before 8, looks like a pressure value, seen with CPAP-Check and EZ-Start + // Maybe ending pressure: matches ending CPAP-Check pressure if it changes mid-session. + // TODO: The daily details will show when it changed, so maybe there's an event that indicates a pressure change. + //CHECK_VALUES(data[pos], 90, 60); // maybe CPAP-Check pressure, also matches EZ-Start Pressure + break; + default: + UNEXPECTED_VALUE(code, "known slice code"); + break; + } + pos += size; + } while (ok && pos < chunk_size); + + this->duration = tt; + + return ok; } bool PRS1Import::ImportSummary() { - if (!summary) return false; + if (!summary) { + qWarning() << "ImportSummary() called with no summary?"; + return false; + } - session->set_first(qint64(summary->timestamp) * 1000L); + qint64 start = qint64(summary->timestamp) * 1000L; + session->set_first(start); session->setPhysMax(CPAP_LeakTotal, 120); session->setPhysMin(CPAP_LeakTotal, 0); @@ -3769,7 +4321,16 @@ bool PRS1Import::ImportSummary() for (int i=0; i < summary->m_parsedData.count(); i++) { PRS1ParsedEvent* e = summary->m_parsedData.at(i); - if (e->m_type != PRS1ParsedSettingEvent::TYPE) { + if (e->m_type == PRS1ParsedSliceEvent::TYPE) { + PRS1ParsedSliceEvent* s = (PRS1ParsedSliceEvent*) e; + qint64 tt = start + qint64(s->m_start) * 1000L; + if (!session->m_slices.isEmpty()) { + SessionSlice & prevSlice = session->m_slices.last(); + prevSlice.end = tt; + } + session->m_slices.append(SessionSlice(tt, tt, (SliceStatus) s->m_value)); + continue; + } else if (e->m_type != PRS1ParsedSettingEvent::TYPE) { qWarning() << "Summary had non-setting event:" << (int) e->m_type; continue; } @@ -3868,19 +4429,42 @@ bool PRS1Import::ImportSummary() if (!ok) { return false; } + summary_duration = summary->duration; + if (summary->duration == 0) { + // This does occasionally happen and merely indicates a brief session with no useful data. + //qDebug() << summary->sessionid << "duration == 0"; + return true; // Don't bail for now, since some summary parsers are still very broken, so we want to proceed to events/waveforms. + } + + // Intentionally don't set the session's duration based on the summary duration. + // That only happens in PRS1Import::ParseSession() as a last resort. + // TODO: Revisit this once summary parsing is reliable. + //session->set_last(...); + return true; } bool PRS1DataChunk::ParseSummary() { + const unsigned char * data = (unsigned char *)this->m_data.constData(); + + // TODO: 7 length 3, 8 length 3 have been seen on 960P, add those value checks once we look more closely at the data. + if (data[0] == 5) { + CHECK_VALUE(this->m_data.size(), 5); // 4 more bytes before CRC, looks like a timestamp + } else if (data[0] == 6) { + CHECK_VALUE(this->m_data.size(), 1); // 0 more bytes before CRC + } else { + CHECK_VALUE(data[0], 0); + } // All machines have a first byte zero for clean summary - if (this->m_data.constData()[0] != 0) { - qDebug() << "Non zero hblock[0] indicator"; + // TODO: this check should move down into the individual family parsers once the V3 parsing below has been relocated. + if (data[0] != 0) { + //qDebug() << this->sessionid << "summary first byte" << data[0] << "!= 0, skipping"; return false; - } + } // TODO: The below mainblock creation is probably wrong. It should move to to its own function when it gets fixed. /* Example data block @@ -3894,7 +4478,6 @@ bool PRS1DataChunk::ParseSummary() 000000c6@0070: 1a 00 38 04] */ if (this->fileVersion == 3) { // Parse summary structures into bytearray map according to size given in header block - const unsigned char * data = (unsigned char *)this->m_data.constData(); int size = this->m_data.size(); int pos = 0; @@ -3910,14 +4493,24 @@ bool PRS1DataChunk::ParseSummary() bsize = it.value(); if (val != 1) { + if (this->hbdata.contains(val)) { + // We know this is entirely wrong. It will be removed after F3V6 is updated. + //qWarning() << this->sessionid << "duplicate hbdata val" << val; + } // store the data block for later reference this->hbdata[val] = QByteArray((const char *)(&data[pos]), bsize); } else { + if (!this->mainblock.isEmpty()) { + qWarning() << this->sessionid << "duplicate mainblock"; + } // Parse the nested data structure which contains settings int p2 = 0; do { val = data[pos + p2++]; len = data[pos + p2++]; + if (this->mainblock.contains(val)) { + qWarning() << this->sessionid << "duplicate mainblock val" << val; + } this->mainblock[val] = QByteArray((const char *)(&data[pos+p2]), len); p2 += len; } while ((p2 < bsize) && ((pos+p2) < size)); @@ -4076,6 +4669,7 @@ bool PRS1Import::ParseEvents() } else { if (!session->settings.contains(CPAP_Pressure) && !session->settings.contains(CPAP_PressureMin)) { + qWarning() << session->session() << "broken summary, missing pressure"; session->settings[CPAP_BrokenSummary] = true; //session->set_last(session->first()); @@ -4169,7 +4763,7 @@ QList<PRS1DataChunk *> PRS1Import::CoalesceWaveformChunks(QList<PRS1DataChunk *> } -bool PRS1Import::ParseOximetery() +bool PRS1Import::ParseOximetry() { int size = oximetry.size(); @@ -4214,12 +4808,21 @@ bool PRS1Import::ParseOximetery() return true; } + +static QString ts(qint64 msecs) +{ + // TODO: make this UTC so that tests don't vary by where they're run + return QDateTime::fromMSecsSinceEpoch(msecs).toString(Qt::ISODate); +} + + bool PRS1Import::ParseWaveforms() { int size = waveforms.size(); quint64 s1, s2; + int discontinuities = 0; qint64 lastti=0; EventList * bnd = nullptr; // Breathing Not Detected @@ -4235,14 +4838,39 @@ bool PRS1Import::ParseWaveforms() quint64 ti = quint64(waveform->timestamp) * 1000L; quint64 dur = qint64(waveform->duration) * 1000L; - quint64 diff = ti - lastti; - if ((lastti != 0) && diff > 0) { - qDebug() << waveform->sessionid << waveform->timestamp << "BND?" << (diff / 1000L) << "=" << waveform->timestamp << "-" << (lastti / 1000L); + qint64 diff = ti - lastti; + if ((lastti != 0) && (diff == 1000 || diff == -1000)) { + // TODO: Evidently the machines' internal clock drifts slightly, and in some sessions that + // means two adjacent (5-minute) waveform chunks have have a +/- 1 second difference in + // their notion of the correct time, since the machines only record time at 1-second + // resolution. Presumably the real drift is fractional, but there's no way to tell from + // the data. + // + // Encore apparently drops the second chunk entirely if it overlaps with the first + // (even by 1 second), and inserts a 1-second gap in the data if it's 1 second later than + // the first ended. + // + // At worst in the former case it seems preferable to drop the overlap and then one + // additional second to mark the discontinuity. But depending how often these drifts + // occur, it may be possible to adjust all the data so that it's continuous. Alternatively, + // if it turns out overlapping waveform data always has overlapping identical values, + // it might be possible to drop the duplicated sample. Though that would mean that + // gaps are real, though potentially only by a single sample. + // + qDebug() << waveform->sessionid << "waveform discontinuity:" << (diff / 1000L) << "s @" << ts(waveform->timestamp * 1000L); + discontinuities++; } - if ((diff > 500) && (lastti != 0)) { + if ((diff > 1000) && (lastti != 0)) { if (!bnd) { bnd = session->AddEventList(PRS1_BND, EVL_Event); } + // TODO: The machines' notion of BND appears to derive from the summary (maskoff/maskon) + // slices, but the waveform data (when present) does seem to agree. This should be confirmed + // once all summary parsers support slices. + if ((diff / 1000L) % 60) { + // Thus far all maskoff/maskon gaps have been multiples of 1 minute. + qDebug() << waveform->sessionid << "BND?" << (diff / 1000L) << "=" << ts(waveform->timestamp * 1000L) << "-" << ts(lastti); + } bnd->AddEvent(ti, double(diff)/1000.0); } @@ -4280,6 +4908,10 @@ bool PRS1Import::ParseWaveforms() } lastti = dur+ti; } + + if (discontinuities > 1) { + qWarning() << session->session() << "multiple discontinuities!" << discontinuities; + } return true; } @@ -4297,39 +4929,95 @@ void PRS1Import::run() bool PRS1Import::ParseSession(void) { + bool ok = false; bool save = false; session = new Session(mach, sessionid); - if ((compliance && ParseCompliance()) || (summary && ImportSummary())) { - if (event && !ParseEvents()) { - } - - // Parse .005 Waveform file - waveforms = loader->ParseFile(wavefile); - waveforms = CoalesceWaveformChunks(waveforms); - if (session->eventlist.contains(CPAP_FlowRate)) { - if (waveforms.size() > 0) { - // Delete anything called "Flow rate" picked up in the events file if real data is present - session->destroyEvent(CPAP_FlowRate); + do { + // TODO: There should be a way to distinguish between no-data-to-import vs. parsing errors + // (once we figure out what's benign and what isn't). + if (compliance != nullptr) { + ok = ImportCompliance(); + if (!ok) { + //qWarning() << sessionid << "Error parsing compliance, skipping session"; + break; + } + } + if (summary != nullptr) { + if (compliance != nullptr) { + qWarning() << sessionid << "Has both compliance and summary?!"; + // Never seen this, but try the summary anyway. + } + ok = ImportSummary(); + if (!ok) { + //qWarning() << sessionid << "Error parsing summary, skipping session"; + break; + } + } + if (compliance == nullptr && summary == nullptr) { + qWarning() << sessionid << "No compliance or summary, skipping session"; + break; + } + + if (event != nullptr) { + ok = ParseEvents(); + if (!ok) { + qWarning() << sessionid << "Error parsing events, proceeding anyway?"; } } - ParseWaveforms(); - // Parse .006 Waveform file - oximetry = loader->ParseFile(oxifile); - oximetry = CoalesceWaveformChunks(oximetry); - ParseOximetery(); + if (!wavefile.isEmpty()) { + // Parse .005 Waveform file + waveforms = loader->ParseFile(wavefile); + waveforms = CoalesceWaveformChunks(waveforms); + if (session->eventlist.contains(CPAP_FlowRate)) { + if (waveforms.size() > 0) { + // Delete anything called "Flow rate" picked up in the events file if real data is present + session->destroyEvent(CPAP_FlowRate); + } + } + ok = ParseWaveforms(); + if (!ok) { + qWarning() << sessionid << "Error parsing waveforms, proceeding anyway?"; + } + } + + if (!oxifile.isEmpty()) { + // Parse .006 Waveform file + oximetry = loader->ParseFile(oxifile); + oximetry = CoalesceWaveformChunks(oximetry); + ok = ParseOximetry(); + if (!ok) { + qWarning() << sessionid << "Error parsing oximetry, proceeding anyway?"; + } + } if (session->first() > 0) { if (session->last() < session->first()) { - // if last isn't set, duration couldn't be gained from summary, parsing events or waveforms.. - // This session is dodgy, so kill it + // Compliance uses set_last() to set the session's last timestamp, so it + // won't reach this point. + if (compliance != nullptr) { + qWarning() << sessionid << "compliance didn't set session end?"; + } + + // Events and waveforms use updateLast() to set the session's last timestamp, + // so they should only reach this point if there was a problem parsing them. + if (event != nullptr || !wavefile.isEmpty() || !oxifile.isEmpty()) { + qWarning() << sessionid << "Downgrading session to summary only"; + } session->setSummaryOnly(true); + + // Only use the summary's duration if the session's duration couldn't be + // derived from events or waveforms. + // TODO: Revisit this once summary parsing is reliable. session->really_set_last(session->first()+(qint64(summary_duration) * 1000L)); } save = true; + } else { + qWarning() << sessionid << "missing start time"; } - } + } while (false); + return save; } @@ -4492,8 +5180,8 @@ PRS1DataChunk* PRS1DataChunk::ParseNext(QFile & f) // Make sure the calculated CRC over the entire chunk (header and data) matches the stored CRC. if (chunk->calcCrc != chunk->storedCrc) { // corrupt data block.. bleh.. - qDebug() << chunk->m_path << "@" << chunk->m_filepos << "block CRC calc" << hex << chunk->calcCrc << "!= stored" << hex << chunk->storedCrc; - //break; // don't break to avoid changing behavior (for now) + qWarning() << chunk->m_path << "@" << chunk->m_filepos << "block CRC calc" << hex << chunk->calcCrc << "!= stored" << hex << chunk->storedCrc; + break; } // Only return the chunk if it has passed all tests above. @@ -4538,7 +5226,7 @@ bool PRS1DataChunk::ReadHeader(QFile & f) } if (this->htype != PRS1_HTYPE_NORMAL && this->htype != PRS1_HTYPE_INTERVAL) { qWarning() << this->m_path << "unexpected htype:" << this->htype; - //break; // don't break to avoid changing behavior (for now) + break; } // Read format-specific variable-length header data. @@ -4675,6 +5363,7 @@ bool PRS1DataChunk::ReadWaveformHeader(QFile & f) header = (unsigned char *)this->m_header.data(); // Parse the variable-length waveform information. + // TODO: move these checks into the parser, after the header checksum has been verified int pos = 0x13; for (int i = 0; i < wvfm_signals; ++i) { quint8 kind = header[pos]; diff --git a/oscar/SleepLib/loader_plugins/prs1_loader.h b/oscar/SleepLib/loader_plugins/prs1_loader.h index 04f24860..0cfd1a4c 100644 --- a/oscar/SleepLib/loader_plugins/prs1_loader.h +++ b/oscar/SleepLib/loader_plugins/prs1_loader.h @@ -25,7 +25,7 @@ //******************************************************************************************** // Please INCREMENT the following value when making changes to this loaders implementation // BEFORE making a release -const int prs1_data_version = 15; +const int prs1_data_version = 16; // //******************************************************************************************** #if 0 // Apparently unused @@ -128,9 +128,15 @@ public: //! \brief Read the chunk's data from a PRS1 file and calculate its CRC, must be called after ReadHeader bool ReadData(class QFile & f); - //! \brief Parse a single data chunk from a .000 file containing compliance data for a brick + //! \brief Figures out which Compliance Parser to call, based on machine family/version and calls it. bool ParseCompliance(void); + //! \brief Parse a single data chunk from a .000 file containing compliance data for a P25x brick + bool ParseComplianceF0V23(void); + + //! \brief Parse a single data chunk from a .000 file containing compliance data for a DreamStation 200X brick + bool ParseComplianceF0V6(void); + //! \brief Figures out which Summary Parser to call, based on machine family/version and calls it. bool ParseSummary(); @@ -155,9 +161,12 @@ public: //! \brief Parse a flex setting byte from a .000 or .001 containing compliance/summary data void ParseFlexSetting(quint8 flex, CPAPMode cpapmode); - //! \brief Parse an humidifier setting byte from a .000 or .001 containing compliance/summary data - void ParseHumidifierSetting(int humid, bool supportsHeatedTubing=true); - + //! \brief Parse an humidifier setting byte from a .000 or .001 containing compliance/summary data for fileversion 2 machines: F0V234, F5V012, and maybe others + void ParseHumidifierSettingV2(int humid, bool supportsHeatedTubing=true); + + //! \brief Parse an humidifier setting byte from a .000 or .001 containing compliance/summary data for family 0 CPAP/APAP family version 6 machines + void ParseHumidifierSettingF0V6(unsigned char byte1, unsigned char byte2, bool add_setting=false); + //! \brief Figures out which Event Parser to call, based on machine family/version and calls it. bool ParseEvents(CPAPMode mode); @@ -191,6 +200,9 @@ protected: //! \brief Extract the stored CRC from the end of the data of a PRS1 chunk bool ExtractStoredCrc(int size); + + //! \brief Parse a settings slice from a .000 (and maybe .001) file + bool ParseSettingsF0V6(const unsigned char* data, int size); }; @@ -233,10 +245,10 @@ public: QString wavefile; QString oxifile; - //! \brief As it says on the tin.. Parses .001 files for bricks. - bool ParseCompliance(); + //! \brief Imports .000 files for bricks. + bool ImportCompliance(); - //! \brief Imports the .002 summary file. + //! \brief Imports the .001 summary file. bool ImportSummary(); //! \brief Figures out which Event Parser to call, based on machine family/version and calls it. @@ -249,7 +261,7 @@ public: bool ParseWaveforms(); //! \brief Takes the parsed list of oximeter waveform chunks and adds them to the database. - bool ParseOximetery(); + bool ParseOximetry(); //! \brief Parse a single data chunk from a .002 file containing event data for a standard system one machine diff --git a/oscar/SleepLib/loader_plugins/resmed_loader.cpp b/oscar/SleepLib/loader_plugins/resmed_loader.cpp index 7e37534c..cbf0fe37 100644 --- a/oscar/SleepLib/loader_plugins/resmed_loader.cpp +++ b/oscar/SleepLib/loader_plugins/resmed_loader.cpp @@ -3117,8 +3117,8 @@ void ResmedLoader::initChannels() QObject::tr("Climate Control"), "", LOOKUP, Qt::black)); - chan->addOption(0, QObject::tr("Manual")); - chan->addOption(1, QObject::tr("Auto")); + chan->addOption(0, QObject::tr("Auto")); + chan->addOption(1, QObject::tr("Manual")); channel.add(GRP_CPAP, chan = new Channel(RMS9_Mask= 0xe20C, SETTING, MT_CPAP, SESSION, "RMS9_Mask", QObject::tr("Mask"), diff --git a/oscar/SleepLib/session.h b/oscar/SleepLib/session.h index 663d053e..79ef360d 100644 --- a/oscar/SleepLib/session.h +++ b/oscar/SleepLib/session.h @@ -1,7 +1,8 @@ -/* SleepLib Session Header +/* SleepLib Session Header * * This stuff contains the session calculation smarts * + * Copyright (c) 2019 The OSCAR Team * Copyright (C) 2011-2018 Mark Watkins <mark@jedimark.net> * * This file is subject to the terms and conditions of the GNU General Public @@ -24,7 +25,7 @@ class Machine; enum SliceStatus { - UnknownStatus=0, EquipmentOff, EquipmentLeaking, EquipmentOn + UnknownStatus=0, EquipmentOff, MaskOn, MaskOff // is there an EquipmentOn? }; class SessionSlice @@ -137,7 +138,7 @@ class Session // t = 0; // for (int i=0; i<size; ++i) { // const SessionSlice & slice = m_slices.at(i); -// if (slice.status == EquipmentOn) { +// if (slice.status == MaskOn) { // t += slice.end - slice.start; // } // } @@ -169,7 +170,7 @@ class Session //! \brief Set last time to higher of 'd' and existing s_last. Throw warning if 'd' less than s_first. void set_last(qint64 d) { if (d <= s_first) { - qWarning() << "Session::set_last() d<=s_first"; + qWarning() << s_session << "Session::set_last() d<=s_first"; return; } @@ -187,7 +188,7 @@ class Session t = 0; for (int i=0; i<size; ++i) { const SessionSlice & slice = m_slices.at(i); - if (slice.status == EquipmentOn) { + if (slice.status == MaskOn) { t += slice.end - slice.start; } } diff --git a/oscar/mainwindow.cpp b/oscar/mainwindow.cpp index f4f9b634..a1d5e7ee 100644 --- a/oscar/mainwindow.cpp +++ b/oscar/mainwindow.cpp @@ -1076,6 +1076,13 @@ void MainWindow::setRecBoxHTML(QString html) { ui->recordsBox->setHtml(html); } + +void MainWindow::setStatsHTML(QString html) +{ + ui->statisticsView->setHtml(html); +} + + /*** QString MainWindow::getWelcomeHTML() { @@ -1404,70 +1411,12 @@ void MainWindow::on_actionPrint_Report_triggered() Report::PrintReport(overview->graphView(), STR_TR_Overview); } else if (ui->tabWidget->currentWidget() == daily) { Report::PrintReport(daily->graphView(), STR_TR_Daily, daily->getDate()); - } else { - QPrinter printer(QPrinter::HighResolution); -#ifdef Q_WS_X11 - printer.setPrinterName("Print to File (PDF)"); - printer.setOutputFormat(QPrinter::PdfFormat); - QString name; - QString datestr; - - if (ui->tabWidget->currentWidget() == ui->statisticsTab) { - name = "Statistics"; - datestr = QDate::currentDate().toString(Qt::ISODate); - } else if (ui->tabWidget->currentWidget() == ui->helpTab) { - name = "Help"; - datestr = QDateTime::currentDateTime().toString(Qt::ISODate); - } else { name = "Unknown"; } - - QString filename = p_pref->Get("{home}/" + name + "_" + p_profile->user->userName() + "_" + datestr + ".pdf"); - - printer.setOutputFileName(filename); -#endif - printer.setPrintRange(QPrinter::AllPages); -// if (ui->tabWidget->currentWidget() == ui->statisticsTab) { -// printer.setOrientation(QPrinter::Landscape); -// } else { - printer.setOrientation(QPrinter::Portrait); - //} - printer.setFullPage(false); // This has nothing to do with scaling - printer.setNumCopies(1); - printer.setResolution(1200); - //printer.setPaperSize(QPrinter::A4); - //printer.setOutputFormat(QPrinter::PdfFormat); - printer.setPageMargins(5, 5, 5, 5, QPrinter::Millimeter); - QPrintDialog pdlg(&printer, this); - - if (pdlg.exec() == QPrintDialog::Accepted) { - - if (ui->tabWidget->currentWidget() == ui->statisticsTab) { - - QTextBrowser b; - QPainter painter; - painter.begin(&printer); - - QRect rect = printer.pageRect(); - b.setHtml(ui->statisticsView->toHtml()); - b.resize(rect.width()/4, rect.height()/4); - b.setFrameShape(QFrame::NoFrame); - - double xscale = printer.pageRect().width()/double(b.width()); - double yscale = printer.pageRect().height()/double(b.height()); - double scale = qMin(xscale, yscale); - painter.translate(printer.paperRect().x() + printer.pageRect().width()/2, printer.paperRect().y() + printer.pageRect().height()/2); - painter.scale(scale, scale); - painter.translate(-b.width()/2, -b.height()/2); - - b.render(&painter, QPoint(0,0)); - painter.end(); - + } else if (ui->tabWidget->currentWidget() == ui->statisticsTab) { + Statistics::printReport(this); #ifndef helpless - } else if (ui->tabWidget->currentWidget() == help) { - help->print(&printer); + } else if (ui->tabWidget->currentWidget() == help) { + help->print(&printer); // **** THIS DID NOT SURVIVE REFACTORING STATISTICS PRINT #endif - } - - } } } @@ -2366,14 +2315,13 @@ void MainWindow::GenerateStatistics() ui->statEndDate->setMaximumDate(last); Statistics stats; - QString html = stats.GenerateHTML(); + QString htmlStats = stats.GenerateHTML(); + QString htmlRecords = stats.UpdateRecordsBox(); updateFavourites(); - //QWebFrame *frame=ui->statisticsView->page()->currentFrame(); - //frame->addToJavaScriptWindowObject("mainwin",this); - //ui->statisticsView->setHtml(html); - ui->statisticsView->setHtml(html); + setStatsHTML(htmlStats); + setRecBoxHTML(htmlRecords); } diff --git a/oscar/mainwindow.h b/oscar/mainwindow.h index 87c91448..25fe95bf 100644 --- a/oscar/mainwindow.h +++ b/oscar/mainwindow.h @@ -160,6 +160,9 @@ class MainWindow : public QMainWindow //! \brief Internal function to set Records Box html from statistics module void setRecBoxHTML(QString html); + //! \brief Internal function to set Statistics page html from statistics module + void setStatsHTML(QString html); + int importCPAP(ImportPath import, const QString &message); void startImportDialog() { on_action_Import_Data_triggered(); } diff --git a/oscar/statistics.cpp b/oscar/statistics.cpp index e3cea829..afa30ed4 100644 --- a/oscar/statistics.cpp +++ b/oscar/statistics.cpp @@ -12,11 +12,23 @@ #include <QBuffer> #include <cmath> +#include <QPrinter> +#include <QPrintDialog> +#include <QPainter> +#include <QMainWindow> + #include "mainwindow.h" #include "statistics.h" extern MainWindow *mainwin; +// HTML components that make up Statistics page and printed report +QString htmlReportHeader = ""; // Page header +QString htmlUsage = ""; // CPAP and Oximetry +QString htmlMachineSettings = ""; // Machine (formerly Rx) changes +QString htmlMachines = ""; // Machines used in this profile +QString htmlReportFooter = ""; // Page footer + QString resizeHTMLPixmap(QPixmap &pixmap, int width, int height) { QByteArray byteArray; QBuffer buffer(&byteArray); // use buffer to store pixmap into byteArray @@ -955,8 +967,8 @@ QString Statistics::getRDIorAHIText() { return STR_TR_AHI; } -// Create the HTML that will be the Statistics page. -QString Statistics::GenerateHTML() +// Create the HTML for CPAP and Oximetry usage +QString Statistics::GenerateCPAPUsage() { QList<Machine *> cpap_machines = p_profile->GetMachines(MT_CPAP); QList<Machine *> oximeters = p_profile->GetMachines(MT_OXIMETER); @@ -975,14 +987,11 @@ QString Statistics::GenerateHTML() } } - // Create HTML header and <body> statement - QString html = htmlHeader(havedata); + QString html = ""; // If we don't have any data, return HTML that says that and we are done if (!havedata) { - html += htmlNoData(); - html += htmlFooter(havedata); - return html; + return ""; } // Find first and last days with valid CPAP data @@ -1011,13 +1020,14 @@ QString Statistics::GenerateHTML() // Compute number of monthly periods for a monthly rather than standard time distribution int number_periods = 0; if (p_profile->general->statReportMode() == STAT_MODE_MONTHLY) { - QDate beginDate = qMax(firstcpap, lastcpap.addYears(-1)); - int beginMonth = beginDate.month(); + int firstMonth = firstcpap.month(); int lastMonth = lastcpap.month(); - if (lastMonth < beginMonth) lastMonth += 12; // handle time extending to next year - number_periods = lastMonth - beginMonth + 1; + if (lastMonth <= firstMonth && firstcpap.year() != lastcpap.year()) + lastMonth += 12; // handle time extending to next year + number_periods = lastMonth - firstMonth + 1; + if (number_periods < 1) { - qDebug() << "*** Begin" << beginDate << "beginMonth" << beginMonth << "lastMonth" << lastMonth << "periods" << number_periods; + qDebug() << "*** Begin" << firstcpap << "beginMonth" << firstMonth << "lastMonth" << lastMonth << "periods" << number_periods; number_periods = 1; } // But not more than one year @@ -1068,19 +1078,6 @@ QString Statistics::GenerateHTML() l = s.addDays(-1); } while ((l > first) && (j < number_periods)); -// for (; j < number_periods; ++j) { -// s=QDate(l.year(), l.month(), 1); -// if (s < first) { -// done = true; -// s = first; -// } -// if (p_profile->countDays(row.type, s, l) > 0) { -// periods.push_back(Period(s, l, s.toString("MMMM"))); -// } else { -// } -// l = s.addDays(-1); -// if (done || (l < first)) break; -// } for (; j < number_periods; ++j) { periods.push_back(Period(last,last, "")); } @@ -1174,21 +1171,91 @@ QString Statistics::GenerateHTML() html += "</table>"; html += "</div>"; - - html += GenerateRXChanges(); - html += GenerateMachineList(); - - UpdateRecordsBox(); - - - - html += "<script type='text/javascript' language='javascript' src='qrc:/docs/script.js'></script>"; - //updateFavourites(); - html += htmlFooter(); return html; } -void Statistics::UpdateRecordsBox() +// Create the HTML that will be the Statistics page. +QString Statistics::GenerateHTML() +{ + htmlReportHeader = htmlHeader(true); + htmlReportFooter = htmlFooter(true); + + htmlUsage = GenerateCPAPUsage(); + + if (htmlUsage == "") { + return htmlReportHeader + htmlNoData() + htmlReportFooter; + } + + htmlMachineSettings = GenerateRXChanges(); + htmlMachines = GenerateMachineList(); + + UpdateRecordsBox(); + + QString htmlScript = "<script type='text/javascript' language='javascript' src='qrc:/docs/script.js'></script>"; + + return htmlReportHeader + htmlUsage + htmlMachineSettings + htmlMachines + htmlScript + htmlReportFooter; +} + +void Statistics::printReport(QWidget * parent) { + + QPrinter printer(QPrinter::HighResolution); +#ifdef Q_OS_LINUX + printer.setPrinterName("Print to File (PDF)"); + printer.setOutputFormat(QPrinter::PdfFormat); + QString name = "Statistics"; + QString datestr = QDate::currentDate().toString(Qt::ISODate); + +// if (ui->tabWidget->currentWidget() == ui->statisticsTab) { +// name = "Statistics"; +// datestr = QDate::currentDate().toString(Qt::ISODate); +// } else if (ui->tabWidget->currentWidget() == ui->helpTab) { +// name = "Help"; +// datestr = QDateTime::currentDateTime().toString(Qt::ISODate); +// } else { name = "Unknown"; } + + QString filename = p_pref->Get("{home}/") + name + "_" + p_profile->user->userName() + "_" + datestr + ".pdf"; + + printer.setOutputFileName(filename); +#endif + printer.setPrintRange(QPrinter::AllPages); +// if (ui->tabWidget->currentWidget() == ui->statisticsTab) { +// printer.setOrientation(QPrinter::Landscape); +// } else { + printer.setOrientation(QPrinter::Portrait); + //} + printer.setFullPage(false); // This has nothing to do with scaling + printer.setNumCopies(1); + printer.setResolution(1200); + //printer.setPaperSize(QPrinter::A4); + //printer.setOutputFormat(QPrinter::PdfFormat); + printer.setPageMargins(5, 5, 5, 5, QPrinter::Millimeter); + QPrintDialog pdlg(&printer, parent); + + if (pdlg.exec() == QPrintDialog::Accepted) { + + QTextBrowser b; + QPainter painter; + painter.begin(&printer); + + QRect rect = printer.pageRect(); + b.setHtml(htmlReportHeader + htmlUsage + htmlMachineSettings + htmlMachines + htmlReportFooter); + b.resize(rect.width()/4, rect.height()/4); + b.setFrameShape(QFrame::NoFrame); + + double xscale = printer.pageRect().width()/double(b.width()); + double yscale = printer.pageRect().height()/double(b.height()); + double scale = qMin(xscale, yscale); + painter.translate(printer.paperRect().x() + printer.pageRect().width()/2, printer.paperRect().y() + printer.pageRect().height()/2); + painter.scale(scale, scale); + painter.translate(-b.width()/2, -b.height()/2); + + b.render(&painter, QPoint(0,0)); + painter.end(); + + } +} + +QString Statistics::UpdateRecordsBox() { QString html = "<html><head><style type='text/css'>" "p,a,td,body { font-family: '" + QApplication::font().family() + "'; }" @@ -1473,7 +1540,8 @@ void Statistics::UpdateRecordsBox() html += "</body></html>"; - mainwin->setRecBoxHTML(html); + + return html; } diff --git a/oscar/statistics.h b/oscar/statistics.h index be33e6d1..4c9b0e4d 100644 --- a/oscar/statistics.h +++ b/oscar/statistics.h @@ -10,6 +10,7 @@ #define SUMMARY_H #include <QObject> +#include <QMainWindow> #include <QHash> #include <QList> #include "SleepLib/schema.h" @@ -168,8 +169,11 @@ class Statistics : public QObject QString GenerateHTML(); QString GenerateMachineList(); QString GenerateRXChanges(); + QString GenerateCPAPUsage(); - void UpdateRecordsBox(); + QString UpdateRecordsBox(); + + static void printReport(QWidget *parent = nullptr); protected: diff --git a/oscar/tests/prs1tests.cpp b/oscar/tests/prs1tests.cpp index 15eb3872..4d26a677 100644 --- a/oscar/tests/prs1tests.cpp +++ b/oscar/tests/prs1tests.cpp @@ -100,12 +100,12 @@ void parseAndEmitSessionYaml(const QString & path) // Run the parser PRS1Import* import = dynamic_cast<PRS1Import*>(task); - import->ParseSession(); + bool ok = import->ParseSession(); // Emit the parsed session data to compare against our regression benchmarks Session* session = import->session; QString outpath = prs1OutputPath(path, m->serial(), session->session(), "-session.yml"); - SessionToYaml(outpath, session); + SessionToYaml(outpath, session, ok); delete session; delete task; @@ -122,9 +122,21 @@ void PRS1Tests::testSessionsToYaml() static QString ts(qint64 msecs) { + // TODO: make this UTC so that tests don't vary by where they're run return QDateTime::fromMSecsSinceEpoch(msecs).toString(Qt::ISODate); } +static QString dur(qint64 msecs) +{ + qint64 s = msecs / 1000L; + int h = s / 3600; s -= h * 3600; + int m = s / 60; s -= m * 60; + return QString("%1:%2:%3") + .arg(h, 2, 10, QChar('0')) + .arg(m, 2, 10, QChar('0')) + .arg(s, 2, 10, QChar('0')); +} + static QString byteList(QByteArray data, int limit=-1) { int count = data.size(); @@ -138,13 +150,12 @@ static QString byteList(QByteArray data, int limit=-1) return s; } -void ChunkToYaml(QFile & file, PRS1DataChunk* chunk) +void ChunkToYaml(QTextStream & out, PRS1DataChunk* chunk, bool ok) { - QTextStream out(&file); - // chunk header out << "chunk:" << endl; out << " at: " << hex << chunk->m_filepos << endl; + out << " parsed: " << ok << endl; out << " version: " << dec << chunk->fileVersion << endl; out << " size: " << chunk->blockSize << endl; out << " htype: " << chunk->htype << endl; @@ -153,6 +164,7 @@ void ChunkToYaml(QFile & file, PRS1DataChunk* chunk) out << " ext: " << chunk->ext << endl; out << " session: " << chunk->sessionid << endl; out << " start: " << ts(chunk->timestamp * 1000L) << endl; + out << " duration: " << dur(chunk->duration * 1000L) << endl; // hblock for V3 non-waveform chunks if (chunk->fileVersion == 3 && chunk->htype == 0) { @@ -210,7 +222,7 @@ void ChunkToYaml(QFile & file, PRS1DataChunk* chunk) } } } - if (dump_data) { + if (dump_data || !ok) { out << " data: " << byteList(chunk->m_data, 100) << endl; } @@ -256,9 +268,13 @@ void parseAndEmitChunkYaml(const QString & path) QFileInfo fi = flist.at(i); QString inpath = fi.canonicalFilePath(); bool ok; + + if (fi.fileName() == ".DS_Store") { + continue; + } QString ext_s = fi.fileName().section(".", -1); - ext_s.toInt(&ok); + int ext = ext_s.toInt(&ok); if (!ok) { // not a numerical extension qWarning() << inpath << "unexpected filename"; @@ -266,7 +282,7 @@ void parseAndEmitChunkYaml(const QString & path) } QString session_s = fi.fileName().section(".", 0, -2); - session_s.toInt(&ok, sessionid_base); + int sessionid = session_s.toInt(&ok, sessionid_base); if (!ok) { // not a numerical session ID qWarning() << inpath << "unexpected filename"; @@ -274,28 +290,36 @@ void parseAndEmitChunkYaml(const QString & path) } // Create the YAML file. - QString outpath = prs1OutputPath(path, m->serial(), fi.fileName(), "-chunks.yml"); + QString suffix = QString(".%1-chunks.yml").arg(ext, 3, 10, QChar('0')); + QString outpath = prs1OutputPath(path, m->serial(), sessionid, suffix); QFile file(outpath); if (!file.open(QFile::WriteOnly | QFile::Truncate)) { qDebug() << outpath; Q_ASSERT(false); } + QTextStream out(&file); + + // keep only P1234568/Pn/00000000.001 + QStringList pathlist = QDir::toNativeSeparators(inpath).split(QDir::separator(), QString::SkipEmptyParts); + QString relative = pathlist.mid(pathlist.size()-3).join(QDir::separator()); + out << "file: " << relative << endl; // Parse the chunks in the file. QList<PRS1DataChunk *> chunks = s_loader->ParseFile(inpath); for (int i=0; i < chunks.size(); i++) { PRS1DataChunk * chunk = chunks.at(i); + bool ok = true; // Parse the inner data. switch (chunk->ext) { - case 0: chunk->ParseCompliance(); break; - case 1: chunk->ParseSummary(); break; - case 2: chunk->ParseEvents(MODE_UNKNOWN); break; + case 0: ok = chunk->ParseCompliance(); break; + case 1: ok = chunk->ParseSummary(); break; + case 2: ok = chunk->ParseEvents(MODE_UNKNOWN); break; default: break; } // Emit the YAML. - ChunkToYaml(file, chunk); + ChunkToYaml(out, chunk, ok); delete chunk; } diff --git a/oscar/tests/sessiontests.cpp b/oscar/tests/sessiontests.cpp index 1701f25d..e63b27df 100644 --- a/oscar/tests/sessiontests.cpp +++ b/oscar/tests/sessiontests.cpp @@ -11,6 +11,7 @@ static QString ts(qint64 msecs) { + // TODO: make this UTC so that tests don't vary by where they're run return QDateTime::fromMSecsSinceEpoch(msecs).toString(Qt::ISODate); } @@ -19,6 +20,17 @@ static QString hex(int i) return QString("0x") + QString::number(i, 16).toUpper(); } +static QString dur(qint64 msecs) +{ + qint64 s = msecs / 1000L; + int h = s / 3600; s -= h * 3600; + int m = s / 60; s -= m * 60; + return QString("%1:%2:%3") + .arg(h, 2, 10, QChar('0')) + .arg(m, 2, 10, QChar('0')) + .arg(s, 2, 10, QChar('0')); +} + #define ENUMSTRING(ENUM) case ENUM: s = #ENUM; break static QString eventListTypeName(EventListType t) { @@ -28,7 +40,7 @@ static QString eventListTypeName(EventListType t) ENUMSTRING(EVL_Event); default: s = hex(t); - qDebug() << qPrintable(s); + qDebug() << "EVL" << qPrintable(s); } return s; } @@ -76,8 +88,9 @@ static QString settingChannel(ChannelID i) CHANNELNAME(PRS1_AutoOff); CHANNELNAME(PRS1_MaskAlert); CHANNELNAME(PRS1_ShowAHI); + CHANNELNAME(CPAP_BrokenSummary); s = hex(i); - qDebug() << qPrintable(s); + qDebug() << "setting channel" << qPrintable(s); } while(false); return s; } @@ -126,9 +139,8 @@ static QString eventChannel(ChannelID i) CHANNELNAME(PRS1_0C); CHANNELNAME(PRS1_0E); CHANNELNAME(PRS1_15); - CHANNELNAME(CPAP_BrokenSummary); s = hex(i); - qDebug() << qPrintable(s); + qDebug() << "event channel" << qPrintable(s); } while(false); return s; } @@ -157,7 +169,7 @@ static QString intList(quint32* data, int count, int limit=-1) return s; } -void SessionToYaml(QString filepath, Session* session) +void SessionToYaml(QString filepath, Session* session, bool ok) { QFile file(filepath); if (!file.open(QFile::WriteOnly | QFile::Truncate)) { @@ -170,6 +182,27 @@ void SessionToYaml(QString filepath, Session* session) out << " id: " << session->session() << endl; out << " start: " << ts(session->first()) << endl; out << " end: " << ts(session->last()) << endl; + out << " valid: " << ok << endl; + + if (!session->m_slices.isEmpty()) { + out << " slices:" << endl; + for (auto & slice : session->m_slices) { + QString s; + switch (slice.status) { + case MaskOn: s = "mask on"; break; + case MaskOff: s = "mask off"; break; + case EquipmentOff: s = "equipment off"; break; + default: s = "unknown"; break; + } + out << " - status: " << s << endl; + out << " start: " << ts(slice.start) << endl; + out << " end: " << ts(slice.end) << endl; + } + } + Day day; + day.addSession(session); + out << " total_time: " << dur(day.total_time()) << endl; + day.removeSession(session); out << " settings:" << endl; @@ -198,13 +231,23 @@ void SessionToYaml(QString filepath, Session* session) // Note that this is a vector of lists QVector<EventList *> &ev = session->eventlist[*key]; int ev_size = ev.size(); + if (ev_size == 0) { + continue; + } + EventList &e = *ev[0]; - // TODO: See what this actually signifies. Some waveform data seems to have to multiple event lists, - // which might reflect blocks within the original files, or something else. - if (ev_size > 2) qDebug() << session->session() << eventChannel(*key) << "ev_size =" << ev_size; + // Multiple eventlists in a channel are used to account for discontiguous data. + // See CoalesceWaveformChunks for the coalescing of multiple contiguous waveform + // chunks and ParseWaveforms/ParseOximetry for the creation of eventlists per + // coalesced chunk. + // + // TODO: Is this only for waveform data? + if (ev_size > 1 && e.type() != EVL_Waveform) { + qWarning() << session->session() << eventChannel(*key) << "ev_size =" << ev_size; + } for (int j = 0; j < ev_size; j++) { - EventList &e = *ev[j]; + e = *ev[j]; out << " - count: " << (qint32)e.count() << endl; if (e.count() == 0) continue; diff --git a/oscar/tests/sessiontests.h b/oscar/tests/sessiontests.h index 01e8e438..03dceeb4 100644 --- a/oscar/tests/sessiontests.h +++ b/oscar/tests/sessiontests.h @@ -11,6 +11,6 @@ #include "../SleepLib/session.h" -void SessionToYaml(QString filepath, Session* session); +void SessionToYaml(QString filepath, Session* session, bool ok); #endif // SESSIONTESTS_H