├── release └── urchin-cgm.pbw ├── config └── images │ ├── layouts.png │ ├── point-styles.png │ └── recency-styles.png ├── test ├── gold │ ├── TestMmol-aplite.png │ ├── TestMmol-basalt.png │ ├── TestLayoutA-aplite.png │ ├── TestLayoutA-basalt.png │ ├── TestLayoutB-aplite.png │ ├── TestLayoutB-basalt.png │ ├── TestLayoutC-aplite.png │ ├── TestLayoutC-basalt.png │ ├── TestLayoutD-aplite.png │ ├── TestLayoutD-basalt.png │ ├── TestLayoutE-aplite.png │ ├── TestLayoutE-basalt.png │ ├── TestBasalGraph-aplite.png │ ├── TestBasalGraph-basalt.png │ ├── TestErrorCodes-aplite.png │ ├── TestErrorCodes-basalt.png │ ├── TestLayoutCustom-aplite.png │ ├── TestLayoutCustom-basalt.png │ ├── TestNiceLayout-aplite.png │ ├── TestNiceLayout-basalt.png │ ├── TestPointsColor-aplite.png │ ├── TestPointsColor-basalt.png │ ├── TestPointsPresetA-aplite.png │ ├── TestPointsPresetA-basalt.png │ ├── TestPointsPresetB-aplite.png │ ├── TestPointsPresetB-basalt.png │ ├── TestPointsPresetC-aplite.png │ ├── TestPointsPresetC-basalt.png │ ├── TestPointsPresetD-aplite.png │ ├── TestPointsPresetD-basalt.png │ ├── TestPositiveDelta-aplite.png │ ├── TestPositiveDelta-basalt.png │ ├── TestTrimmingValues-aplite.png │ ├── TestTrimmingValues-basalt.png │ ├── TestBasicIntegration-aplite.png │ ├── TestBasicIntegration-basalt.png │ ├── TestBatteryAsNumber-aplite.png │ ├── TestBatteryAsNumber-basalt.png │ ├── TestBlackBackground-aplite.png │ ├── TestBlackBackground-basalt.png │ ├── TestDegenerateEntries-aplite.png │ ├── TestDegenerateEntries-basalt.png │ ├── TestDynamicTimeFont10-aplite.png │ ├── TestDynamicTimeFont10-basalt.png │ ├── TestDynamicTimeFont14-aplite.png │ ├── TestDynamicTimeFont14-basalt.png │ ├── TestDynamicTimeFont18-aplite.png │ ├── TestDynamicTimeFont18-basalt.png │ ├── TestDynamicTimeFont6-aplite.png │ ├── TestDynamicTimeFont6-basalt.png │ ├── TestPredictionsColors-aplite.png │ ├── TestPredictionsColors-basalt.png │ ├── TestRecencySuperOld-aplite.png │ ├── TestRecencySuperOld-basalt.png │ ├── TestStaleServerData-aplite.png │ ├── TestStaleServerData-basalt.png │ ├── TestStatusTextTooLong-aplite.png │ ├── TestStatusTextTooLong-basalt.png │ ├── TestPredictionsDefault-aplite.png │ ├── TestPredictionsDefault-basalt.png │ ├── TestPredictionsWithAGap-aplite.png │ ├── TestPredictionsWithAGap-basalt.png │ ├── TestPointsBarelyOffScreen-aplite.png │ ├── TestPointsBarelyOffScreen-basalt.png │ ├── TestPointsBarelyOnScreen-aplite.png │ ├── TestPointsBarelyOnScreen-basalt.png │ ├── TestPointsBolusesDefault-aplite.png │ ├── TestPointsBolusesDefault-basalt.png │ ├── TestPointsCircleAlignment-aplite.png │ ├── TestPointsCircleAlignment-basalt.png │ ├── TestPointsColorCustomLine-aplite.png │ ├── TestPointsColorCustomLine-basalt.png │ ├── TestPointsMissingWithLine-aplite.png │ ├── TestPointsMissingWithLine-basalt.png │ ├── TestPointsNegativeMargin-aplite.png │ ├── TestPointsNegativeMargin-basalt.png │ ├── TestPredictionsMaxLength-aplite.png │ ├── TestPredictionsMaxLength-basalt.png │ ├── TestGraphBoundsAndGridlines-aplite.png │ ├── TestGraphBoundsAndGridlines-basalt.png │ ├── TestPointsBolusesCenteredOdd-aplite.png │ ├── TestPointsBolusesCenteredOdd-basalt.png │ ├── TestSGVsAtBoundsAndGridlines-aplite.png │ ├── TestSGVsAtBoundsAndGridlines-basalt.png │ ├── TestStatusHiddenAfterMaxAge-aplite.png │ ├── TestStatusHiddenAfterMaxAge-basalt.png │ ├── TestStatusRecencyOverOneHour-aplite.png │ ├── TestStatusRecencyOverOneHour-basalt.png │ ├── TestPointsBolusesCenteredEven-aplite.png │ ├── TestPointsBolusesCenteredEven-basalt.png │ ├── TestPointsMarginsWithTreatments-aplite.png │ ├── TestPointsMarginsWithTreatments-basalt.png │ ├── TestPredictionsNegativeMargin-aplite.png │ ├── TestPredictionsNegativeMargin-basalt.png │ ├── TestPredictionsSGVBarelyStale-aplite.png │ ├── TestPredictionsSGVBarelyStale-basalt.png │ ├── TestPredictionsSGVNotQuiteStale-aplite.png │ ├── TestPredictionsSGVNotQuiteStale-basalt.png │ ├── TestRecencyLongTextLeftAligned-aplite.png │ ├── TestRecencyLongTextLeftAligned-basalt.png │ ├── TestRecencyLongTextRightAligned-aplite.png │ ├── TestRecencyLongTextRightAligned-basalt.png │ ├── TestNotRecentButNotYetStaleBGRow-aplite.png │ ├── TestNotRecentButNotYetStaleBGRow-basalt.png │ ├── TestPredictionsWithMultipleSeries-aplite.png │ ├── TestPredictionsWithMultipleSeries-basalt.png │ ├── TestStatusRecencyFormatColonLeft-aplite.png │ ├── TestStatusRecencyFormatColonLeft-basalt.png │ ├── TestStatusRecencyShownAfterMinAge-aplite.png │ ├── TestStatusRecencyShownAfterMinAge-basalt.png │ ├── TestBatteryLocInStatusMinimumPadding-aplite.png │ ├── TestBatteryLocInStatusMinimumPadding-basalt.png │ ├── TestPointsColorLineWithMissingPoints-aplite.png │ ├── TestPointsColorLineWithMissingPoints-basalt.png │ ├── TestPredictionsWithBolusesAndBasals-aplite.png │ ├── TestPredictionsWithBolusesAndBasals-basalt.png │ ├── TestRecencyLargePieGraphBottomLeft-aplite.png │ ├── TestRecencyLargePieGraphBottomLeft-basalt.png │ ├── TestRecencyMediumRingTimeBottomRight-aplite.png │ ├── TestRecencyMediumRingTimeBottomRight-basalt.png │ ├── TestStatusRecencyFormatBracketRight-aplite.png │ ├── TestStatusRecencyFormatBracketRight-basalt.png │ ├── TestStatusRecencyHiddenBeforeMinAge-aplite.png │ ├── TestStatusRecencyHiddenBeforeMinAge-basalt.png │ ├── TestPredictionsSGVFreshPredictionStale-aplite.png │ ├── TestPredictionsSGVFreshPredictionStale-basalt.png │ ├── TestPredictionsSGVStalePredictionFresh-aplite.png │ ├── TestPredictionsSGVStalePredictionFresh-basalt.png │ ├── TestRecencyMediumPieStatusBottomRight-aplite.png │ ├── TestRecencyMediumPieStatusBottomRight-basalt.png │ ├── TestRecencySmallNoCircleStatusTopRight-aplite.png │ ├── TestRecencySmallNoCircleStatusTopRight-basalt.png │ ├── TestRecencyStatusBarVerticallyCentered-aplite.png │ ├── TestRecencyStatusBarVerticallyCentered-basalt.png │ ├── TestRecencyConnStatusBottomLeftWithBasal-aplite.png │ ├── TestRecencyConnStatusBottomLeftWithBasal-basalt.png │ ├── TestBatteryLocInStatusAlignedWithLastLineOfText-aplite.png │ └── TestBatteryLocInStatusAlignedWithLastLineOfText-basalt.png ├── js │ ├── make_mock_local_storage.js │ ├── package.json │ ├── test_debounce.js │ ├── profiler.js │ ├── test_format.js │ ├── test_points.js │ └── test_cache.js ├── do_screenshots.sh ├── live_reload.sh ├── set_config.py ├── ensure-pebble-test-environment.sh ├── server.py └── util.py ├── resources └── images │ ├── arrow_flat.png │ ├── battery_10.png │ ├── battery_100.png │ ├── battery_25.png │ ├── battery_50.png │ ├── battery_75.png │ ├── arrow_double_up.png │ ├── arrow_single_up.png │ ├── battery_charging.png │ ├── conn_issue_rig.png │ ├── conn_refreshing.png │ ├── arrow_double_down.png │ ├── arrow_single_down.png │ ├── conn_issue_network.png │ ├── arrow_forty_five_down.png │ ├── arrow_forty_five_up.png │ └── conn_issue_bluetooth.png ├── .gitignore ├── requirements.txt ├── src ├── text_updates.h ├── js │ ├── debug.js │ ├── debounce.js │ ├── points.js │ ├── ga.js │ ├── cache.js │ ├── status_formatters.js │ ├── format.js │ └── vendor │ │ └── lie.polyfill.js ├── format.h ├── fonts.h ├── battery_component.h ├── sidebar_element.h ├── time_element.h ├── bg_row_element.h ├── staleness.h ├── status_bar_element.h ├── layout.h ├── trend_arrow_component.h ├── graph_element.h ├── config.h ├── recency_component.h ├── text_updates.c ├── connection_status_component.h ├── fonts.c ├── app_messages.h ├── comm.h ├── sidebar_element.c ├── trend_arrow_component.c ├── format.c ├── staleness.c ├── bg_row_element.c ├── preferences.h ├── status_bar_element.c ├── battery_component.c ├── time_element.c ├── preferences.c ├── main.c ├── recency_component.c ├── layout.c ├── connection_status_component.c ├── app_messages.c └── comm.c ├── circle.yml ├── LICENSE ├── proxy_config.html ├── make_inline_config.py ├── wscript └── package.json /release/urchin-cgm.pbw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/release/urchin-cgm.pbw -------------------------------------------------------------------------------- /config/images/layouts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/config/images/layouts.png -------------------------------------------------------------------------------- /test/gold/TestMmol-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestMmol-aplite.png -------------------------------------------------------------------------------- /test/gold/TestMmol-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestMmol-basalt.png -------------------------------------------------------------------------------- /config/images/point-styles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/config/images/point-styles.png -------------------------------------------------------------------------------- /config/images/recency-styles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/config/images/recency-styles.png -------------------------------------------------------------------------------- /resources/images/arrow_flat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/arrow_flat.png -------------------------------------------------------------------------------- /resources/images/battery_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/battery_10.png -------------------------------------------------------------------------------- /resources/images/battery_100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/battery_100.png -------------------------------------------------------------------------------- /resources/images/battery_25.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/battery_25.png -------------------------------------------------------------------------------- /resources/images/battery_50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/battery_50.png -------------------------------------------------------------------------------- /resources/images/battery_75.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/battery_75.png -------------------------------------------------------------------------------- /test/gold/TestLayoutA-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutA-aplite.png -------------------------------------------------------------------------------- /test/gold/TestLayoutA-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutA-basalt.png -------------------------------------------------------------------------------- /test/gold/TestLayoutB-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutB-aplite.png -------------------------------------------------------------------------------- /test/gold/TestLayoutB-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutB-basalt.png -------------------------------------------------------------------------------- /test/gold/TestLayoutC-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutC-aplite.png -------------------------------------------------------------------------------- /test/gold/TestLayoutC-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutC-basalt.png -------------------------------------------------------------------------------- /test/gold/TestLayoutD-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutD-aplite.png -------------------------------------------------------------------------------- /test/gold/TestLayoutD-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutD-basalt.png -------------------------------------------------------------------------------- /test/gold/TestLayoutE-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutE-aplite.png -------------------------------------------------------------------------------- /test/gold/TestLayoutE-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutE-basalt.png -------------------------------------------------------------------------------- /resources/images/arrow_double_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/arrow_double_up.png -------------------------------------------------------------------------------- /resources/images/arrow_single_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/arrow_single_up.png -------------------------------------------------------------------------------- /resources/images/battery_charging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/battery_charging.png -------------------------------------------------------------------------------- /resources/images/conn_issue_rig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/conn_issue_rig.png -------------------------------------------------------------------------------- /resources/images/conn_refreshing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/conn_refreshing.png -------------------------------------------------------------------------------- /test/gold/TestBasalGraph-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBasalGraph-aplite.png -------------------------------------------------------------------------------- /test/gold/TestBasalGraph-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBasalGraph-basalt.png -------------------------------------------------------------------------------- /test/gold/TestErrorCodes-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestErrorCodes-aplite.png -------------------------------------------------------------------------------- /test/gold/TestErrorCodes-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestErrorCodes-basalt.png -------------------------------------------------------------------------------- /test/gold/TestLayoutCustom-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutCustom-aplite.png -------------------------------------------------------------------------------- /test/gold/TestLayoutCustom-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestLayoutCustom-basalt.png -------------------------------------------------------------------------------- /test/gold/TestNiceLayout-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestNiceLayout-aplite.png -------------------------------------------------------------------------------- /test/gold/TestNiceLayout-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestNiceLayout-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsColor-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsColor-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsColor-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsColor-basalt.png -------------------------------------------------------------------------------- /resources/images/arrow_double_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/arrow_double_down.png -------------------------------------------------------------------------------- /resources/images/arrow_single_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/arrow_single_down.png -------------------------------------------------------------------------------- /resources/images/conn_issue_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/conn_issue_network.png -------------------------------------------------------------------------------- /test/gold/TestPointsPresetA-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsPresetA-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsPresetA-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsPresetA-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsPresetB-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsPresetB-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsPresetB-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsPresetB-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsPresetC-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsPresetC-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsPresetC-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsPresetC-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsPresetD-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsPresetD-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsPresetD-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsPresetD-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPositiveDelta-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPositiveDelta-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPositiveDelta-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPositiveDelta-basalt.png -------------------------------------------------------------------------------- /test/gold/TestTrimmingValues-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestTrimmingValues-aplite.png -------------------------------------------------------------------------------- /test/gold/TestTrimmingValues-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestTrimmingValues-basalt.png -------------------------------------------------------------------------------- /resources/images/arrow_forty_five_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/arrow_forty_five_down.png -------------------------------------------------------------------------------- /resources/images/arrow_forty_five_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/arrow_forty_five_up.png -------------------------------------------------------------------------------- /resources/images/conn_issue_bluetooth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/resources/images/conn_issue_bluetooth.png -------------------------------------------------------------------------------- /test/gold/TestBasicIntegration-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBasicIntegration-aplite.png -------------------------------------------------------------------------------- /test/gold/TestBasicIntegration-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBasicIntegration-basalt.png -------------------------------------------------------------------------------- /test/gold/TestBatteryAsNumber-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBatteryAsNumber-aplite.png -------------------------------------------------------------------------------- /test/gold/TestBatteryAsNumber-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBatteryAsNumber-basalt.png -------------------------------------------------------------------------------- /test/gold/TestBlackBackground-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBlackBackground-aplite.png -------------------------------------------------------------------------------- /test/gold/TestBlackBackground-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBlackBackground-basalt.png -------------------------------------------------------------------------------- /test/gold/TestDegenerateEntries-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDegenerateEntries-aplite.png -------------------------------------------------------------------------------- /test/gold/TestDegenerateEntries-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDegenerateEntries-basalt.png -------------------------------------------------------------------------------- /test/gold/TestDynamicTimeFont10-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDynamicTimeFont10-aplite.png -------------------------------------------------------------------------------- /test/gold/TestDynamicTimeFont10-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDynamicTimeFont10-basalt.png -------------------------------------------------------------------------------- /test/gold/TestDynamicTimeFont14-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDynamicTimeFont14-aplite.png -------------------------------------------------------------------------------- /test/gold/TestDynamicTimeFont14-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDynamicTimeFont14-basalt.png -------------------------------------------------------------------------------- /test/gold/TestDynamicTimeFont18-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDynamicTimeFont18-aplite.png -------------------------------------------------------------------------------- /test/gold/TestDynamicTimeFont18-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDynamicTimeFont18-basalt.png -------------------------------------------------------------------------------- /test/gold/TestDynamicTimeFont6-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDynamicTimeFont6-aplite.png -------------------------------------------------------------------------------- /test/gold/TestDynamicTimeFont6-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestDynamicTimeFont6-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsColors-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsColors-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsColors-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsColors-basalt.png -------------------------------------------------------------------------------- /test/gold/TestRecencySuperOld-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencySuperOld-aplite.png -------------------------------------------------------------------------------- /test/gold/TestRecencySuperOld-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencySuperOld-basalt.png -------------------------------------------------------------------------------- /test/gold/TestStaleServerData-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStaleServerData-aplite.png -------------------------------------------------------------------------------- /test/gold/TestStaleServerData-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStaleServerData-basalt.png -------------------------------------------------------------------------------- /test/gold/TestStatusTextTooLong-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusTextTooLong-aplite.png -------------------------------------------------------------------------------- /test/gold/TestStatusTextTooLong-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusTextTooLong-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsDefault-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsDefault-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsDefault-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsDefault-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsWithAGap-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsWithAGap-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsWithAGap-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsWithAGap-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsBarelyOffScreen-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBarelyOffScreen-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsBarelyOffScreen-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBarelyOffScreen-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsBarelyOnScreen-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBarelyOnScreen-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsBarelyOnScreen-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBarelyOnScreen-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsBolusesDefault-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBolusesDefault-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsBolusesDefault-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBolusesDefault-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsCircleAlignment-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsCircleAlignment-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsCircleAlignment-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsCircleAlignment-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsColorCustomLine-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsColorCustomLine-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsColorCustomLine-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsColorCustomLine-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsMissingWithLine-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsMissingWithLine-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsMissingWithLine-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsMissingWithLine-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsNegativeMargin-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsNegativeMargin-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsNegativeMargin-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsNegativeMargin-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsMaxLength-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsMaxLength-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsMaxLength-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsMaxLength-basalt.png -------------------------------------------------------------------------------- /test/gold/TestGraphBoundsAndGridlines-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestGraphBoundsAndGridlines-aplite.png -------------------------------------------------------------------------------- /test/gold/TestGraphBoundsAndGridlines-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestGraphBoundsAndGridlines-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsBolusesCenteredOdd-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBolusesCenteredOdd-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsBolusesCenteredOdd-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBolusesCenteredOdd-basalt.png -------------------------------------------------------------------------------- /test/gold/TestSGVsAtBoundsAndGridlines-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestSGVsAtBoundsAndGridlines-aplite.png -------------------------------------------------------------------------------- /test/gold/TestSGVsAtBoundsAndGridlines-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestSGVsAtBoundsAndGridlines-basalt.png -------------------------------------------------------------------------------- /test/gold/TestStatusHiddenAfterMaxAge-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusHiddenAfterMaxAge-aplite.png -------------------------------------------------------------------------------- /test/gold/TestStatusHiddenAfterMaxAge-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusHiddenAfterMaxAge-basalt.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyOverOneHour-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyOverOneHour-aplite.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyOverOneHour-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyOverOneHour-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsBolusesCenteredEven-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBolusesCenteredEven-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsBolusesCenteredEven-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsBolusesCenteredEven-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsMarginsWithTreatments-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsMarginsWithTreatments-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsMarginsWithTreatments-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsMarginsWithTreatments-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsNegativeMargin-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsNegativeMargin-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsNegativeMargin-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsNegativeMargin-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsSGVBarelyStale-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsSGVBarelyStale-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsSGVBarelyStale-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsSGVBarelyStale-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsSGVNotQuiteStale-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsSGVNotQuiteStale-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsSGVNotQuiteStale-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsSGVNotQuiteStale-basalt.png -------------------------------------------------------------------------------- /test/gold/TestRecencyLongTextLeftAligned-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyLongTextLeftAligned-aplite.png -------------------------------------------------------------------------------- /test/gold/TestRecencyLongTextLeftAligned-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyLongTextLeftAligned-basalt.png -------------------------------------------------------------------------------- /test/gold/TestRecencyLongTextRightAligned-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyLongTextRightAligned-aplite.png -------------------------------------------------------------------------------- /test/gold/TestRecencyLongTextRightAligned-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyLongTextRightAligned-basalt.png -------------------------------------------------------------------------------- /test/gold/TestNotRecentButNotYetStaleBGRow-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestNotRecentButNotYetStaleBGRow-aplite.png -------------------------------------------------------------------------------- /test/gold/TestNotRecentButNotYetStaleBGRow-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestNotRecentButNotYetStaleBGRow-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsWithMultipleSeries-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsWithMultipleSeries-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsWithMultipleSeries-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsWithMultipleSeries-basalt.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyFormatColonLeft-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyFormatColonLeft-aplite.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyFormatColonLeft-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyFormatColonLeft-basalt.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyShownAfterMinAge-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyShownAfterMinAge-aplite.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyShownAfterMinAge-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyShownAfterMinAge-basalt.png -------------------------------------------------------------------------------- /test/gold/TestBatteryLocInStatusMinimumPadding-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBatteryLocInStatusMinimumPadding-aplite.png -------------------------------------------------------------------------------- /test/gold/TestBatteryLocInStatusMinimumPadding-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBatteryLocInStatusMinimumPadding-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPointsColorLineWithMissingPoints-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsColorLineWithMissingPoints-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPointsColorLineWithMissingPoints-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPointsColorLineWithMissingPoints-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsWithBolusesAndBasals-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsWithBolusesAndBasals-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsWithBolusesAndBasals-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsWithBolusesAndBasals-basalt.png -------------------------------------------------------------------------------- /test/gold/TestRecencyLargePieGraphBottomLeft-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyLargePieGraphBottomLeft-aplite.png -------------------------------------------------------------------------------- /test/gold/TestRecencyLargePieGraphBottomLeft-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyLargePieGraphBottomLeft-basalt.png -------------------------------------------------------------------------------- /test/gold/TestRecencyMediumRingTimeBottomRight-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyMediumRingTimeBottomRight-aplite.png -------------------------------------------------------------------------------- /test/gold/TestRecencyMediumRingTimeBottomRight-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyMediumRingTimeBottomRight-basalt.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyFormatBracketRight-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyFormatBracketRight-aplite.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyFormatBracketRight-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyFormatBracketRight-basalt.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyHiddenBeforeMinAge-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyHiddenBeforeMinAge-aplite.png -------------------------------------------------------------------------------- /test/gold/TestStatusRecencyHiddenBeforeMinAge-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestStatusRecencyHiddenBeforeMinAge-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsSGVFreshPredictionStale-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsSGVFreshPredictionStale-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsSGVFreshPredictionStale-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsSGVFreshPredictionStale-basalt.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsSGVStalePredictionFresh-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsSGVStalePredictionFresh-aplite.png -------------------------------------------------------------------------------- /test/gold/TestPredictionsSGVStalePredictionFresh-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestPredictionsSGVStalePredictionFresh-basalt.png -------------------------------------------------------------------------------- /test/gold/TestRecencyMediumPieStatusBottomRight-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyMediumPieStatusBottomRight-aplite.png -------------------------------------------------------------------------------- /test/gold/TestRecencyMediumPieStatusBottomRight-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyMediumPieStatusBottomRight-basalt.png -------------------------------------------------------------------------------- /test/gold/TestRecencySmallNoCircleStatusTopRight-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencySmallNoCircleStatusTopRight-aplite.png -------------------------------------------------------------------------------- /test/gold/TestRecencySmallNoCircleStatusTopRight-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencySmallNoCircleStatusTopRight-basalt.png -------------------------------------------------------------------------------- /test/gold/TestRecencyStatusBarVerticallyCentered-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyStatusBarVerticallyCentered-aplite.png -------------------------------------------------------------------------------- /test/gold/TestRecencyStatusBarVerticallyCentered-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyStatusBarVerticallyCentered-basalt.png -------------------------------------------------------------------------------- /test/gold/TestRecencyConnStatusBottomLeftWithBasal-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyConnStatusBottomLeftWithBasal-aplite.png -------------------------------------------------------------------------------- /test/gold/TestRecencyConnStatusBottomLeftWithBasal-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestRecencyConnStatusBottomLeftWithBasal-basalt.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | src/generated 3 | src/js/generated 4 | config/js/generated 5 | test/output 6 | .lock-waf* 7 | *.pyc 8 | __pycache__ 9 | node_modules 10 | .idea 11 | *.iml 12 | -------------------------------------------------------------------------------- /test/gold/TestBatteryLocInStatusAlignedWithLastLineOfText-aplite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBatteryLocInStatusAlignedWithLastLineOfText-aplite.png -------------------------------------------------------------------------------- /test/gold/TestBatteryLocInStatusAlignedWithLastLineOfText-basalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifkyprayoga/urchin-cgm/HEAD/test/gold/TestBatteryLocInStatusAlignedWithLastLineOfText-basalt.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask==0.10.1 2 | pytest==2.6.1 3 | python-dateutil==2.5.3 4 | requests==2.3.0 5 | simplejson==3.8.0 6 | watchdog==0.8.3 7 | -e git+https://github.com/pebble/pebble-tool.git@v4.2.1#egg=pebble-tool 8 | -------------------------------------------------------------------------------- /src/text_updates.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "app_messages.h" 5 | 6 | void last_bg_text_layer_update(TextLayer *text_layer, DataMessage *data); 7 | void delta_text_layer_update(TextLayer *text_layer, DataMessage *data); 8 | -------------------------------------------------------------------------------- /src/js/debug.js: -------------------------------------------------------------------------------- 1 | /* global module, console */ 2 | 3 | var Debug = function(c) { 4 | return { 5 | log: function(str) { 6 | if (c.DEBUG) { 7 | console.log(str); 8 | } 9 | } 10 | }; 11 | }; 12 | 13 | module.exports = Debug; 14 | -------------------------------------------------------------------------------- /test/js/make_mock_local_storage.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | "use strict"; 3 | 4 | module.exports = function makeMockLocalStorage() { 5 | var store = {}; 6 | return { 7 | setItem: function(key, val) { store[key] = val; }, 8 | getItem: function(key) { return store[key]; } 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/format.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "app_messages.h" 5 | 6 | void format_bg(char* buffer, char buf_size, int16_t mgdl, bool is_delta, bool use_mmol); 7 | void format_recency(char* buffer, uint16_t buf_size, int32_t seconds); 8 | void format_status_bar_text(char* buffer, uint16_t buf_size, DataMessage *d); 9 | -------------------------------------------------------------------------------- /test/js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "urchin-cgm-js-tests", 3 | "private": true, 4 | "version": "0.0.1", 5 | "scripts": { 6 | "test": "node_modules/.bin/mocha test*.js" 7 | }, 8 | "engines": { 9 | "node": "0.12.x" 10 | }, 11 | "dependencies": { 12 | "expect.js": "0.3.x", 13 | "mocha": "2.3.x", 14 | "mocha-jenkins-reporter": "0.2.3", 15 | "timekeeper": "0.0.5" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/fonts.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | // https://forums.getpebble.com/discussion/7147/text-layer-padding 6 | typedef struct FontChoice { 7 | const char *key; 8 | uint8_t height; 9 | unsigned int padding_top:4; 10 | unsigned int padding_bottom:4; 11 | } FontChoice; 12 | 13 | enum { 14 | FONT_18_BOLD, 15 | FONT_24_BOLD, 16 | FONT_28_BOLD, 17 | FONT_34_NUMBERS, 18 | FONT_42_BOLD, 19 | }; 20 | 21 | FontChoice get_font(uint8_t font_size); 22 | -------------------------------------------------------------------------------- /src/battery_component.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | typedef struct BatteryComponent { 6 | BitmapLayer *icon_layer; 7 | GBitmap *icon_bitmap; 8 | TextLayer *text_layer; 9 | } BatteryComponent; 10 | 11 | uint8_t battery_component_width(); 12 | uint8_t battery_component_height(); 13 | uint8_t battery_component_vertical_padding(); 14 | BatteryComponent* battery_component_create(Layer *parent, int16_t x, int16_t y, bool align_right); 15 | void battery_component_destroy(BatteryComponent *c); 16 | -------------------------------------------------------------------------------- /src/sidebar_element.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "app_messages.h" 5 | #include "trend_arrow_component.h" 6 | 7 | typedef struct SidebarElement { 8 | TextLayer *last_bg_text; 9 | TrendArrowComponent *trend; 10 | TextLayer *delta_text; 11 | } SidebarElement; 12 | 13 | SidebarElement* sidebar_element_create(Layer *parent); 14 | void sidebar_element_destroy(SidebarElement *el); 15 | void sidebar_element_update(SidebarElement *el, DataMessage *data); 16 | void sidebar_element_tick(SidebarElement *el); 17 | -------------------------------------------------------------------------------- /src/time_element.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "app_messages.h" 5 | #include "battery_component.h" 6 | #include "recency_component.h" 7 | 8 | typedef struct TimeElement { 9 | TextLayer *time_text; 10 | BatteryComponent *battery; 11 | RecencyComponent *recency; 12 | } TimeElement; 13 | 14 | TimeElement* time_element_create(Layer *parent); 15 | void time_element_destroy(TimeElement *el); 16 | void time_element_update(TimeElement *el, DataMessage *data); 17 | void time_element_tick(TimeElement *el); 18 | -------------------------------------------------------------------------------- /src/bg_row_element.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "app_messages.h" 5 | #include "trend_arrow_component.h" 6 | 7 | typedef struct BGRowElement { 8 | GSize parent_size; 9 | TextLayer *bg_text; 10 | TrendArrowComponent *trend; 11 | TextLayer *delta_text; 12 | } BGRowElement; 13 | 14 | BGRowElement* bg_row_element_create(Layer *parent); 15 | void bg_row_element_destroy(BGRowElement *el); 16 | void bg_row_element_update(BGRowElement *el, DataMessage *data); 17 | void bg_row_element_tick(BGRowElement *el); 18 | -------------------------------------------------------------------------------- /src/staleness.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "comm.h" 4 | 5 | enum { 6 | CONNECTION_ISSUE_NONE, 7 | CONNECTION_ISSUE_BLUETOOTH, 8 | CONNECTION_ISSUE_NETWORK, 9 | CONNECTION_ISSUE_RIG, 10 | }; 11 | 12 | typedef struct ConnectionIssue { 13 | unsigned int reason:2; 14 | uint32_t staleness; 15 | } ConnectionIssue; 16 | 17 | uint32_t sgv_graph_padding(); 18 | ConnectionIssue connection_issue(); 19 | void init_staleness(); 20 | void staleness_on_request_state_changed(RequestState state); 21 | void staleness_on_data_received(int32_t recency); 22 | -------------------------------------------------------------------------------- /src/status_bar_element.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "app_messages.h" 5 | #include "battery_component.h" 6 | #include "recency_component.h" 7 | 8 | typedef struct StatusBarElement { 9 | TextLayer *text; 10 | BatteryComponent *battery; 11 | RecencyComponent *recency; 12 | } StatusBarElement; 13 | 14 | StatusBarElement* status_bar_element_create(Layer *parent); 15 | void status_bar_element_destroy(StatusBarElement *el); 16 | void status_bar_element_update(StatusBarElement *el, DataMessage *data); 17 | void status_bar_element_tick(StatusBarElement *el); 18 | -------------------------------------------------------------------------------- /src/layout.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "preferences.h" 5 | 6 | typedef struct LayoutLayers { 7 | Layer *graph; 8 | Layer *sidebar; 9 | Layer *status_bar; 10 | Layer *time_area; 11 | Layer *bg_row; 12 | } LayoutLayers; 13 | 14 | TextLayer* add_text_layer(Layer *parent, GRect bounds, GFont font, GColor fg_color, GTextAlignment alignment); 15 | GColor element_bg(Layer* layer); 16 | GColor element_fg(Layer* layer); 17 | GCompOp element_comp_op(Layer* layer); 18 | ElementConfig* get_element_data(Layer* layer); 19 | GRect element_get_bounds(Layer* layer); 20 | LayoutLayers init_layout(Window *window); 21 | void deinit_layout(); 22 | -------------------------------------------------------------------------------- /src/trend_arrow_component.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "app_messages.h" 5 | 6 | typedef struct TrendArrowComponent { 7 | BitmapLayer *icon_layer; 8 | GBitmap *icon_bitmap; 9 | int8_t last_trend; 10 | } TrendArrowComponent; 11 | 12 | uint8_t trend_arrow_component_width(); 13 | uint8_t trend_arrow_component_height(); 14 | TrendArrowComponent* trend_arrow_component_create(Layer *parent, int16_t x, int16_t y); 15 | void trend_arrow_component_destroy(TrendArrowComponent *c); 16 | void trend_arrow_component_update(TrendArrowComponent *c, DataMessage *data); 17 | void trend_arrow_component_reposition(TrendArrowComponent *c, int16_t x, int16_t y); 18 | bool trend_arrow_component_hidden(TrendArrowComponent *c); 19 | -------------------------------------------------------------------------------- /src/graph_element.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "app_messages.h" 5 | #include "comm.h" 6 | #include "connection_status_component.h" 7 | #include "recency_component.h" 8 | 9 | typedef struct GraphElement { 10 | Layer *graph_layer; 11 | ConnectionStatusComponent *conn_status; 12 | RecencyComponent *recency; 13 | } GraphElement; 14 | 15 | typedef struct GraphData { 16 | GColor color; 17 | } GraphData; 18 | 19 | GraphElement* graph_element_create(Layer *parent); 20 | void graph_element_destroy(GraphElement *el); 21 | void graph_element_update(GraphElement *el, DataMessage *data); 22 | void graph_element_tick(GraphElement *el); 23 | void graph_element_show_request_state(GraphElement *el, RequestState state, AppMessageResult reason); 24 | -------------------------------------------------------------------------------- /src/config.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | /////////////////////////////////////////////////////// 6 | // CONFIGURATION: edit any of these values 7 | 8 | // Default delay between the timestamp of the last SGV reading and the 9 | // next request for data 10 | #define SGV_UPDATE_FREQUENCY_SECONDS (5*60 + 30) 11 | 12 | // Even though the data is technically "stale" if we don't have a new 13 | // reading every 5 minutes, there is some lag between the components 14 | // of rig -> web -> phone -> Pebble. Give the data some extra time to 15 | // propagate through the system before shifting the graph to the left 16 | // to indicate staleness. 17 | #define GRAPH_STALENESS_GRACE_PERIOD_SECONDS (3*60) 18 | 19 | /////////////////////////////////////////////////////// 20 | -------------------------------------------------------------------------------- /src/js/debounce.js: -------------------------------------------------------------------------------- 1 | /* global module */ 2 | 3 | /* 4 | * When a function which makes a network request is called multiple times in a 5 | * short period (e.g. its data is needed in two different contexts during a 6 | * single watch/phone request cycle), return the same Promise. 7 | * 8 | * NOTE: Data returned by the Promise is not immutable, so be careful about 9 | * transforming it. 10 | */ 11 | function debounce(fn) { 12 | var lastCallTime = -Infinity; 13 | var lastResult; 14 | 15 | return function() { 16 | if (Date.now() - lastCallTime < debounce.MEMOIZE_PERIOD_MS) { 17 | return lastResult; 18 | } else { 19 | lastCallTime = Date.now(); 20 | lastResult = fn.apply(this, arguments); 21 | return lastResult; 22 | } 23 | }; 24 | } 25 | 26 | debounce.MEMOIZE_PERIOD_MS = 1000; 27 | 28 | module.exports = debounce; 29 | -------------------------------------------------------------------------------- /src/recency_component.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | typedef struct RecencyComponent { 6 | Layer *circle_layer; 7 | } RecencyComponent; 8 | 9 | typedef struct RecencyProps { 10 | bool align_right; 11 | GColor parent_bg; 12 | GColor parent_fg; 13 | void (*size_changed_callback)(GSize, void*); 14 | void *size_changed_context; 15 | } RecencyProps; 16 | 17 | typedef struct RecencyStyle { 18 | uint8_t font; 19 | uint8_t diameter; 20 | uint8_t inset; 21 | } RecencyStyle; 22 | 23 | uint16_t recency_component_height(); 24 | uint16_t recency_component_padding(); 25 | RecencyComponent* recency_component_create(Layer *parent, uint16_t y, bool align_right, void (*size_changed_callback)(GSize, void*), void *size_changed_context); 26 | void recency_component_destroy(RecencyComponent *c); 27 | void recency_component_tick(RecencyComponent *c); 28 | -------------------------------------------------------------------------------- /test/do_screenshots.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export BUILD_ENV=test 4 | export MOCK_SERVER_PORT=5555 5 | 6 | TEST_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 7 | 8 | # Start & background Flask server 9 | python "$TEST_DIR/server.py" & PID=$! 10 | sleep 1 11 | bg 12 | 13 | # Run tests 14 | py.test test/ -v $@ 15 | TEST_RESULT=$? 16 | 17 | if [ $CIRCLECI ] && [ $TEST_RESULT -ne 0 ]; then 18 | # Run it again in case tests are just flaky 19 | py.test test/ -v $@ 20 | TEST_RESULT=$? 21 | fi 22 | 23 | pebble kill 24 | 25 | # Kill Flask server 26 | kill -9 $PID 27 | 28 | unset BUILD_ENV 29 | unset MOCK_SERVER_PORT 30 | 31 | if [ $CIRCLECI ]; then 32 | exit $TEST_RESULT 33 | else 34 | # Try to open the result in a browser 35 | OUT_FILE="$TEST_DIR/output/screenshots.html" 36 | [ `command -v open` ] && open $OUT_FILE 37 | [ `command -v xdg-open` ] && xdg-open $OUT_FILE 38 | fi 39 | -------------------------------------------------------------------------------- /src/text_updates.c: -------------------------------------------------------------------------------- 1 | #include "app_messages.h" 2 | #include "format.h" 3 | #include "preferences.h" 4 | #include "staleness.h" 5 | #include "text_updates.h" 6 | 7 | void last_bg_text_layer_update(TextLayer *text_layer, DataMessage *data) { 8 | static char last_bg_buffer[8]; 9 | format_bg(last_bg_buffer, sizeof(last_bg_buffer), data->last_sgv, false, get_prefs()->mmol); 10 | text_layer_set_text(text_layer, last_bg_buffer); 11 | } 12 | 13 | void delta_text_layer_update(TextLayer *text_layer, DataMessage *data) { 14 | static char delta_buffer[8]; 15 | int delta; 16 | if (sgv_graph_padding() > 0) { 17 | delta = NO_DELTA_VALUE; 18 | } else { 19 | delta = data->delta; 20 | } 21 | 22 | if (delta == NO_DELTA_VALUE) { 23 | text_layer_set_text(text_layer, "-"); 24 | } else { 25 | format_bg(delta_buffer, sizeof(delta_buffer), delta, true, get_prefs()->mmol); 26 | text_layer_set_text(text_layer, delta_buffer); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/connection_status_component.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | typedef struct ConnectionStatusComponent { 6 | BitmapLayer *icon_layer; 7 | GBitmap *icon_bitmap; 8 | TextLayer *reason_text; 9 | GColor background; 10 | bool align_bottom; 11 | GRect parent_bounds; 12 | int16_t initial_x; 13 | int16_t initial_y; 14 | bool is_showing_request_state; 15 | } ConnectionStatusComponent; 16 | 17 | ConnectionStatusComponent* connection_status_component_create(Layer *parent, int16_t x, int16_t y, bool align_bottom); 18 | void connection_status_component_destroy(ConnectionStatusComponent *c); 19 | void connection_status_component_tick(ConnectionStatusComponent *c); 20 | void connection_status_component_update_offset(ConnectionStatusComponent* c, GSize size); 21 | void connection_status_component_show_request_state(ConnectionStatusComponent *c, RequestState state, AppMessageResult reason); 22 | uint16_t connection_status_component_size(); 23 | -------------------------------------------------------------------------------- /test/live_reload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A hacky script to make test-driven iteration much faster. 3 | # 4 | # Call with the name of a test class defined in test_screenshots.py, and it 5 | # will reload the emulator with its config and data on every file change. 6 | # 7 | # For other options, see: python test/set_config.py -h 8 | 9 | run () { 10 | TEST_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 11 | 12 | export MOCK_SERVER_PORT=5555 13 | set -x 14 | 15 | python "$TEST_DIR/server.py" --test-class "$@" & PID=$! 16 | sleep 1 17 | bg 18 | 19 | python "$TEST_DIR/set_config.py" --test-class $@ 20 | 21 | # Requires watchdog 22 | COMMAND="python $TEST_DIR/set_config.py --test-class $@" 23 | watchmedo shell-command --patterns="**/test_screenshots.py" --recursive --command="$COMMAND" $TEST_DIR 24 | 25 | kill -9 $PID 26 | 27 | set +x 28 | unset MOCK_SERVER_PORT 29 | } 30 | 31 | if [[ $# -eq 0 ]] ; then 32 | echo 'Name of test class is required argument' 33 | else 34 | run $@ 35 | fi 36 | -------------------------------------------------------------------------------- /src/js/points.js: -------------------------------------------------------------------------------- 1 | /* global module, window */ 2 | 3 | function points(c) { 4 | var p = {}; 5 | 6 | p.computeGraphWidth = function(layout) { 7 | var graph = layout.elements.filter(function(e) { return c.ELEMENTS[e.el] === 'GRAPH_ELEMENT'; })[0]; 8 | if (graph !== undefined) { 9 | return Math.round(c.SCREEN_WIDTH * graph.width / 100); 10 | } else { 11 | return 0; 12 | } 13 | }; 14 | 15 | p.computeVisiblePoints = function(width, config) { 16 | var available = width - config.pointRightMargin; 17 | var points = Math.floor((available + Math.max(0, config.pointMargin)) / (config.pointWidth + config.pointMargin)); 18 | if (config.pointMargin < 0) { 19 | points += 1; 20 | } 21 | return Math.max(0, points); 22 | }; 23 | 24 | return p; 25 | } 26 | 27 | // TODO: include this in the config page in a better way 28 | if (typeof(module) !== 'undefined') { 29 | module.exports = points; 30 | } 31 | if (typeof(window) !== 'undefined') { 32 | window.points = points; 33 | } 34 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | python: 3 | version: 2.7.12 4 | node: 5 | version: 0.12.0 6 | environment: 7 | PEBBLE_TOOL: 4.3 8 | PEBBLE_SDK: 3.14 9 | post: 10 | - npm install -g npm@3.x.x 11 | 12 | dependencies: 13 | pre: 14 | - . test/ensure-pebble-test-environment.sh 15 | override: 16 | - cd test/js; npm install 17 | cache_directories: 18 | - ~/.pebble-sdk 19 | - ~/pebble-dev 20 | - ~/imagemagick 21 | 22 | test: 23 | pre: 24 | - mkdir -p $CIRCLE_TEST_REPORTS/js-unit-tests 25 | - mkdir -p $CIRCLE_TEST_REPORTS/screenshots 26 | override: 27 | - cd test/js; node_modules/.bin/mocha test*.js --reporter mocha-jenkins-reporter: 28 | environment: 29 | JUNIT_REPORT_PATH: $CIRCLE_TEST_REPORTS/js-unit-tests/junit.xml 30 | JUNIT_STACK: 1 31 | - . ~/pebble-dev/path.sh; pebble build 32 | - . ~/pebble-dev/path.sh; . test/do_screenshots.sh --junitxml=$CIRCLE_TEST_REPORTS/screenshots/junit.xml 33 | post: 34 | - cp -r test/output $CIRCLE_ARTIFACTS/output 35 | -------------------------------------------------------------------------------- /test/js/test_debounce.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | /* globals describe, it */ 3 | "use strict"; 4 | 5 | var expect = require('expect.js'), 6 | timekeeper = require('timekeeper'); 7 | 8 | var debounce = require('../../src/js/debounce.js'); 9 | 10 | describe('debounce', function() { 11 | function fn(i) { 12 | return { 13 | calledWith: i, 14 | calledAt: new Date() 15 | }; 16 | } 17 | 18 | it('should return the same value if called within MEMOIZE_PERIOD_MS ms of the last call', function() { 19 | var debounced = debounce(fn); 20 | var now = new Date(); 21 | timekeeper.freeze(now); 22 | var first = debounced(1); 23 | timekeeper.freeze(new Date(now.getTime() + debounce.MEMOIZE_PERIOD_MS - 1)); 24 | var second = debounced(2); 25 | timekeeper.freeze(new Date(now.getTime() + debounce.MEMOIZE_PERIOD_MS + 1)); 26 | var third = debounced(3); 27 | timekeeper.freeze(new Date(now.getTime() + debounce.MEMOIZE_PERIOD_MS + 3)); 28 | var fourth = debounced(4); 29 | expect(first).to.be(second); 30 | expect(second).not.to.be(third); 31 | expect(third).to.be(fourth); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mark Wilson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/set_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Send Urchin config to the Pebble emulator, either from the `config` dict in a 3 | test class definition or from a JSON string. For the former case, best run with 4 | `watchdog` or similar to monitor file changes. 5 | """ 6 | 7 | import argparse 8 | import json 9 | import os 10 | import sys 11 | 12 | import test_screenshots 13 | from util import BASE_CONFIG 14 | from util import set_config 15 | 16 | PORT = os.environ.get('MOCK_SERVER_PORT') 17 | 18 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter) 19 | parser.add_argument('--test-class', help='name of test class (e.g. TestLayoutD)') 20 | parser.add_argument('--config-json', help='JSON config string to set') 21 | parser.add_argument('--platform', default='basalt', help='which emulator') 22 | args = parser.parse_args() 23 | 24 | if args.test_class: 25 | test_instance = getattr(test_screenshots, args.test_class)() 26 | config = dict(BASE_CONFIG, **getattr(test_instance, 'config', {})) 27 | elif args.config_json: 28 | config = json.loads(args.config_json) 29 | else: 30 | print "Must specify either --test-class or --config-json" 31 | sys.exit(1) 32 | config['__CLEAR_CACHE__'] = True 33 | 34 | if PORT: 35 | config['nightscout_url'] = 'http://localhost:{}'.format(PORT) 36 | 37 | set_config(config, [args.platform]) 38 | -------------------------------------------------------------------------------- /src/fonts.c: -------------------------------------------------------------------------------- 1 | #include "fonts.h" 2 | 3 | FontChoice get_font(uint8_t font_size) { 4 | switch(font_size) { 5 | 6 | case FONT_18_BOLD: 7 | return (FontChoice) { 8 | .key = FONT_KEY_GOTHIC_18_BOLD, 9 | .height = 11, 10 | .padding_top = 7, 11 | .padding_bottom = 3, 12 | }; 13 | 14 | case FONT_24_BOLD: 15 | return (FontChoice) { 16 | .key = FONT_KEY_GOTHIC_24_BOLD, 17 | .height = 14, 18 | .padding_top = 10, 19 | .padding_bottom = 4, 20 | }; 21 | 22 | case FONT_28_BOLD: 23 | return (FontChoice) { 24 | .key = FONT_KEY_GOTHIC_28_BOLD, 25 | .height = 18, 26 | .padding_top = 10, 27 | .padding_bottom = 4, 28 | }; 29 | 30 | case FONT_34_NUMBERS: 31 | return (FontChoice) { 32 | .key = FONT_KEY_BITHAM_34_MEDIUM_NUMBERS, 33 | .height = 24, 34 | .padding_top = 10, 35 | .padding_bottom = 0, 36 | }; 37 | 38 | case FONT_42_BOLD: 39 | return (FontChoice) { 40 | .key = FONT_KEY_BITHAM_42_BOLD, 41 | .height = 30, 42 | .padding_top = 12, 43 | .padding_bottom = 8, 44 | }; 45 | 46 | default: 47 | return (FontChoice) { 48 | .key = FONT_KEY_GOTHIC_24_BOLD, 49 | .height = 14, 50 | .padding_top = 10, 51 | .padding_bottom = 4, 52 | }; 53 | 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /proxy_config.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | Config Page Emulator 13 | 14 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/app_messages.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #define GRAPH_MAX_SGV_COUNT 144 6 | #define STATUS_BAR_MAX_LENGTH 256 7 | #define PREDICTION_MAX_LENGTH 60 8 | #define NO_DELTA_VALUE 65536 9 | 10 | typedef union GraphExtra { 11 | uint8_t raw; 12 | struct { 13 | uint8_t bolus:1; 14 | uint8_t basal:5; 15 | uint8_t _unused:2; 16 | }; 17 | } GraphExtra; 18 | 19 | typedef struct __attribute__((__packed__)) DataMessage { 20 | time_t received_at; 21 | int32_t recency; 22 | uint16_t sgv_count; 23 | uint8_t sgvs[GRAPH_MAX_SGV_COUNT]; 24 | int32_t last_sgv; 25 | int32_t trend; 26 | int32_t delta; 27 | char status_text[STATUS_BAR_MAX_LENGTH]; 28 | int32_t status_recency; 29 | GraphExtra graph_extra[GRAPH_MAX_SGV_COUNT]; 30 | uint8_t prediction_length; 31 | uint8_t prediction_1[PREDICTION_MAX_LENGTH]; 32 | uint8_t prediction_2[PREDICTION_MAX_LENGTH]; 33 | uint8_t prediction_3[PREDICTION_MAX_LENGTH]; 34 | int32_t prediction_recency; 35 | } DataMessage; 36 | 37 | bool get_int32(DictionaryIterator *data, int32_t *dest, uint8_t key, bool required, int32_t fallback); 38 | bool get_byte_array(DictionaryIterator *data, uint8_t *dest, uint8_t key, size_t max_length, bool required, uint8_t *fallback); 39 | bool get_byte_array_length(DictionaryIterator *data, uint16_t *dest, uint16_t max_length, uint8_t key); 40 | bool get_cstring(DictionaryIterator *data, char *dest, uint8_t key, size_t max_length, bool required, const char* fallback); 41 | bool validate_data_message(DictionaryIterator *data, DataMessage *out); 42 | 43 | void save_last_data_message(DataMessage *d); 44 | DataMessage *last_data_message(); 45 | -------------------------------------------------------------------------------- /src/comm.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include "app_messages.h" 5 | 6 | // This can theoretically be maxed out to 984 bytes by combining: 7 | // - status bar text of 255 characters 8 | // - point width of 1px (144 points + 144 "graph extra") 9 | // - 3 prediction series of length 60 10 | #define CONTENT_SIZE 1024 11 | 12 | // There are many failure modes... 13 | #define INITIAL_TIMEOUT_HALVED 2500 14 | #define DEFAULT_TIMEOUT (10*1000) 15 | #define MISSING_INITIAL_DATA_ALERT (5*1000) 16 | #define TIMEOUT_RETRY_DELAY (20*1000) 17 | #define NO_BLUETOOTH_RETRY_DELAY (60*1000) 18 | #define SEND_FAILED_DELAY (60*1000) 19 | #define OUT_RETRY_DELAY (20*1000) 20 | #define IN_RETRY_DELAY 100 21 | #define LATE_DATA_RETRY_PERIOD_SECONDS 60 22 | #define LATE_DATA_UPDATE_FREQUENCY_SECONDS 60 23 | #define ERROR_RETRY_DELAY (60*1000) 24 | #define BAD_APP_MESSAGE_RETRY_DELAY (60*1000) 25 | 26 | enum { 27 | MSG_TYPE_ERROR, 28 | MSG_TYPE_DATA, 29 | MSG_TYPE_PREFERENCES, 30 | }; 31 | 32 | typedef enum { 33 | REQUEST_STATE_WAITING, 34 | REQUEST_STATE_SUCCESS, 35 | REQUEST_STATE_FETCH_ERROR, 36 | REQUEST_STATE_BAD_APP_MESSAGE, 37 | REQUEST_STATE_TIMED_OUT, 38 | REQUEST_STATE_NO_BLUETOOTH, 39 | REQUEST_STATE_OUT_FAILED, 40 | REQUEST_STATE_IN_DROPPED, 41 | REQUEST_STATE_BEGIN_FAILED, 42 | REQUEST_STATE_SEND_FAILED, 43 | REQUEST_STATE_OPEN_FAILED, 44 | } RequestState; 45 | 46 | void init_comm( 47 | void (*callback_for_data)(DataMessage *data), 48 | void (*callback_for_prefs)(DictionaryIterator *received), 49 | void (*callback_for_request_state)(RequestState state, AppMessageResult reason) 50 | ); 51 | void deinit_comm(); 52 | bool comm_is_update_in_progress(); 53 | -------------------------------------------------------------------------------- /test/ensure-pebble-test-environment.sh: -------------------------------------------------------------------------------- 1 | # For use on CircleCI 2 | # 3 | # https://developer.pebble.com/sdk/install/linux/ 4 | # http://cathaines.com/2016/03/building-pebble-apps-with-travis-ci/ 5 | 6 | set -x 7 | set -e 8 | 9 | PEBBLE_TOOL_PATH=pebble-sdk-$PEBBLE_TOOL-linux64 10 | 11 | ######## Python testing dependencies 12 | 13 | pip install -r ~/urchin-cgm/requirements.txt 14 | 15 | ######## Pebble CLI tool 16 | 17 | if [ ! -e ~/pebble-dev/$PEBBLE_TOOL_PATH ]; then 18 | mkdir -p ~/pebble-dev 19 | cd ~/pebble-dev 20 | wget https://s3.amazonaws.com/assets.getpebble.com/pebble-tool/$PEBBLE_TOOL_PATH.tar.bz2 21 | tar -jxf $PEBBLE_TOOL_PATH.tar.bz2 22 | 23 | mkdir -p ~/.pebble-sdk 24 | touch ~/.pebble-sdk/NO_TRACKING 25 | 26 | cd ~/pebble-dev/$PEBBLE_TOOL_PATH 27 | virtualenv --no-site-packages .env 28 | source .env/bin/activate 29 | pip install -r requirements.txt 30 | deactivate 31 | 32 | echo 'export PATH=~/pebble-dev/'$PEBBLE_TOOL_PATH'/bin:$PATH' > ~/pebble-dev/path.sh 33 | fi 34 | 35 | ######## Pebble emulator 36 | 37 | sudo apt-get install libsdl1.2debian libfdt1 libpixman-1-0 38 | 39 | ######## Pebble SDK 40 | 41 | . ~/pebble-dev/path.sh 42 | # Ignore bad return code when SDK is already installed 43 | (yes | pebble sdk install $PEBBLE_SDK) || true 44 | pebble sdk activate $PEBBLE_SDK 45 | 46 | ######## ImageMagick 47 | 48 | if [ ! -e ~/imagemagick ]; then 49 | # http://www.imagemagick.org/script/install-source.php 50 | mkdir ~/imagemagick 51 | cd ~/imagemagick 52 | wget http://www.imagemagick.org/download/ImageMagick.tar.gz 53 | tar -xvzf ImageMagick.tar.gz 54 | cd `ls -1 | grep ^ImageMagick-` 55 | ./configure 56 | make 57 | fi 58 | cd ~/imagemagick/`ls -1 ~/imagemagick/ | grep ^ImageMagick-` 59 | sudo make install 60 | sudo ldconfig /usr/local/lib 61 | (convert logo: logo.gif && rm logo.gif) || { echo "ImageMagick install failed"; exit 1; } 62 | -------------------------------------------------------------------------------- /src/js/ga.js: -------------------------------------------------------------------------------- 1 | /* global module */ 2 | 3 | function track(data, trackingId, watchInfo, config) { 4 | // redact PII 5 | var current = JSON.parse(JSON.stringify(config)); 6 | delete current['nightscout_url']; 7 | delete current['dexcomUsername']; 8 | delete current['dexcomPassword']; 9 | delete current['statusText']; 10 | delete current['statusUrl']; 11 | delete current['statusJsonUrl']; 12 | 13 | // GA limits paths to 2048 bytes, so send config and customLayout separately 14 | // https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#dp 15 | var customLayout = current['customLayout']; 16 | delete current['customLayout']; 17 | 18 | var ts = Math.round(new Date().getTime() / 1000); 19 | trackParams(data, trackingId, ts, watchInfo, {current: current}); 20 | if (current['layout'] === 'custom') { 21 | trackParams(data, trackingId, ts, watchInfo, {customLayout: customLayout}); 22 | } 23 | } 24 | 25 | function trackParams(data, trackingId, timestamp, watchInfo, extra) { 26 | var out = {ts: timestamp}; 27 | [watchInfo, extra].forEach(function(obj) { 28 | Object.keys(obj).forEach(function(key) { 29 | if (obj[key] !== undefined) { 30 | out[key] = obj[key]; 31 | } 32 | }); 33 | }); 34 | var host = 'mddub.github.io'; 35 | var path = '/?c=' + JSON.stringify(out); 36 | return sendPageview(data, trackingId, watchInfo.wt, host, path); 37 | } 38 | 39 | function sendPageview(data, trackingId, clientId, host, path) { 40 | // https://developers.google.com/analytics/devguides/collection/protocol/v1/reference#payload 41 | var payload = [ 42 | ['v', '1'], 43 | ['tid', trackingId], 44 | ['cid', clientId], 45 | ['t', 'pageview'], 46 | ['dh', host], 47 | ['dp', path], 48 | ].map(function(pair) { 49 | return pair[0] + '=' + encodeURIComponent(pair[1]); 50 | }).join('&'); 51 | 52 | return data.postURL('https://www.google-analytics.com/collect', {}, payload); 53 | } 54 | 55 | module.exports = { 56 | track: track 57 | }; 58 | -------------------------------------------------------------------------------- /src/sidebar_element.c: -------------------------------------------------------------------------------- 1 | #include "fonts.h" 2 | #include "layout.h" 3 | #include "sidebar_element.h" 4 | #include "text_updates.h" 5 | 6 | SidebarElement* sidebar_element_create(Layer *parent) { 7 | GRect bounds = element_get_bounds(parent); 8 | FontChoice font = get_font(FONT_24_BOLD); 9 | 10 | int16_t trend_arrow_y = (bounds.size.h - trend_arrow_component_height()) / 2; 11 | int16_t last_bg_y = (trend_arrow_y / 4 + bounds.size.h / 8) - font.height / 2 - font.padding_top; 12 | int16_t delta_y = ((bounds.size.h + trend_arrow_y + trend_arrow_component_height()) / 4 + bounds.size.h * 3 / 8) - font.height / 2 - font.padding_top; 13 | 14 | TextLayer *last_bg_text = add_text_layer( 15 | parent, 16 | GRect(0, last_bg_y, bounds.size.w, font.height + font.padding_top + font.padding_bottom), 17 | fonts_get_system_font(font.key), 18 | element_fg(parent), 19 | GTextAlignmentCenter 20 | ); 21 | 22 | TrendArrowComponent *trend = trend_arrow_component_create(parent, (bounds.size.w - trend_arrow_component_width()) / 2, trend_arrow_y); 23 | 24 | TextLayer *delta_text = add_text_layer( 25 | parent, 26 | GRect(0, delta_y, bounds.size.w, font.height + font.padding_top + font.padding_bottom), 27 | fonts_get_system_font(font.key), 28 | element_fg(parent), 29 | GTextAlignmentCenter 30 | ); 31 | 32 | SidebarElement* el = malloc(sizeof(SidebarElement)); 33 | el->last_bg_text = last_bg_text; 34 | el->trend = trend; 35 | el->delta_text = delta_text; 36 | return el; 37 | } 38 | 39 | void sidebar_element_destroy(SidebarElement *el) { 40 | text_layer_destroy(el->last_bg_text); 41 | trend_arrow_component_destroy(el->trend); 42 | text_layer_destroy(el->delta_text); 43 | free(el); 44 | } 45 | 46 | void sidebar_element_update(SidebarElement *el, DataMessage *data) { 47 | last_bg_text_layer_update(el->last_bg_text, data); 48 | trend_arrow_component_update(el->trend, data); 49 | delta_text_layer_update(el->delta_text, data); 50 | } 51 | 52 | void sidebar_element_tick(SidebarElement *el) {} 53 | -------------------------------------------------------------------------------- /test/js/profiler.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | 3 | /* 4 | * Run the Urchin request loop repeatedly, for profiling. 5 | * 6 | * This requires installing a few Node modules: 7 | * npm install v8-profiler 8 | * npm install node-inspector 9 | * npm install xmlhttprequest 10 | * 11 | * Run this from the command line with a file containing Urchin config JSON: 12 | * node --debug profiler.js config.json 13 | * 14 | * Config JSON can be obtained by running the emulator, watching logs with 15 | * `pebble logs`, opening the settings page with `pebble emu-app-config`, 16 | * saving, and copying the result from the "Preferences updated:" line. 17 | * 18 | * Then run separately: 19 | * node-inspector 20 | * and use the Chrome debugger to take heap snapshots. 21 | * 22 | * Resources: 23 | * https://github.com/felixge/node-memory-leak-tutorial 24 | * https://www.youtube.com/watch?v=L3ugr9BJqIs 25 | * 26 | * (Committing this mostly so I can repurpose it later for integration tests.) 27 | */ 28 | 29 | require('v8-profiler'); 30 | 31 | global.localStorage = require('./make_mock_local_storage.js')(); 32 | global.XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest; 33 | 34 | var configFile = process.argv[2]; 35 | if (configFile === undefined) { 36 | console.error('Missing config.json argument'); 37 | process.exit(1); 38 | } 39 | var config = JSON.parse(require('fs').readFileSync(configFile)); 40 | 41 | var _getItem = global.localStorage.getItem; 42 | global.localStorage.getItem = function(key) { 43 | if (key === c.LOCAL_STORAGE_KEY_CONFIG) { 44 | return JSON.stringify(config); 45 | } else { 46 | return _getItem(key); 47 | } 48 | }; 49 | 50 | var _messageHandler; 51 | var Pebble = {}; 52 | Pebble.addEventListener = function(e, fn) { 53 | if (e === 'ready') { 54 | fn(); 55 | } else if (e === 'appmessage') { 56 | _messageHandler = fn; 57 | } 58 | }; 59 | Pebble.sendAppMessage = function() { 60 | console.log('[' + new Date().toISOString() + ' SENT]\n'); 61 | }; 62 | 63 | var c = require('../../src/js/constants.json'); 64 | var app = require('../../src/js/app'); 65 | app(Pebble, c); 66 | 67 | (function loop() { 68 | _messageHandler({payload: {}}); 69 | setTimeout(loop, 15 * 1000); 70 | })(); 71 | -------------------------------------------------------------------------------- /src/js/cache.js: -------------------------------------------------------------------------------- 1 | /* jshint browser: true */ 2 | /* global module, console */ 3 | 4 | var Cache = function() {}; 5 | 6 | Cache.prototype.init = function(key) { 7 | this.storageKey = 'cache_' + key; 8 | this.entries = []; 9 | this.revive(); 10 | }; 11 | 12 | Cache.prototype.revive = function() { 13 | var cached = localStorage.getItem(this.storageKey); 14 | if (cached) { 15 | try { 16 | this.entries = JSON.parse(cached); 17 | } catch (e) { 18 | console.log('Bad value for ' + this.storageKey + ': ' + cached); 19 | } 20 | } 21 | }; 22 | 23 | Cache.prototype.persist = function() { 24 | localStorage.setItem(this.storageKey, JSON.stringify(this.entries)); 25 | }; 26 | 27 | Cache.prototype.update = function(newEntries) { 28 | this.entries = newEntries.concat(this.entries); 29 | this.purge(); 30 | this.persist(); 31 | return this.entries; 32 | }; 33 | 34 | Cache.prototype.clear = function() { 35 | this.entries = []; 36 | this.persist(); 37 | }; 38 | 39 | //////////////// 40 | 41 | var CacheWithMaxAge = function(key, maxSecondsOld) { 42 | this.init(key); 43 | this.maxSecondsOld = maxSecondsOld; 44 | }; 45 | 46 | CacheWithMaxAge.prototype = Object.create(Cache.prototype); 47 | 48 | CacheWithMaxAge.prototype.purge = function() { 49 | this.entries = this.entries.filter(function(e) { 50 | // For now, assume "date" holds the timestamp, formatted as epoch milliseconds 51 | // (This generally holds only for the "entries" collection) 52 | return e['date'] >= Date.now() - this.maxSecondsOld * 1000; 53 | }.bind(this)); 54 | }; 55 | 56 | CacheWithMaxAge.prototype.setMaxSecondsOld = function(newMax) { 57 | this.maxSecondsOld = newMax; 58 | }; 59 | 60 | //////////////// 61 | 62 | var CacheWithMaxSize = function(key, maxSize) { 63 | this.init(key); 64 | this.maxSize = maxSize; 65 | }; 66 | 67 | CacheWithMaxSize.prototype = Object.create(Cache.prototype); 68 | 69 | CacheWithMaxSize.prototype.purge = function() { 70 | this.entries = this.entries.slice(0, this.maxSize); 71 | }; 72 | 73 | CacheWithMaxSize.prototype.setMaxSize = function(newMax) { 74 | this.maxSize = newMax; 75 | }; 76 | 77 | //////////////// 78 | 79 | module.exports = { 80 | WithMaxAge: CacheWithMaxAge, 81 | WithMaxSize: CacheWithMaxSize, 82 | }; 83 | -------------------------------------------------------------------------------- /test/js/test_format.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | /* globals describe, it */ 3 | "use strict"; 4 | 5 | var expect = require('expect.js'); 6 | 7 | var Format = require('../../src/js/format.js'); 8 | 9 | var constants = require('../../src/js/constants.json'); 10 | 11 | describe('format', function() { 12 | var MAX_SGVS = 48; 13 | var format = Format(constants); 14 | 15 | function expectZeroesForGraph(arr) { 16 | expect(arr.length).to.be(MAX_SGVS); 17 | arr.forEach(function(y) { 18 | expect(y).to.be(0); 19 | }); 20 | } 21 | 22 | describe('sgvArray', function() { 23 | it('should return an array of zeroes when there is no data', function() { 24 | expectZeroesForGraph( 25 | format.sgvArray(Date.now(), [], MAX_SGVS) 26 | ); 27 | }); 28 | }); 29 | 30 | describe('basalRateArray', function() { 31 | it('should return the basal rate which was active for the most time during each 5-minute graph interval', function() { 32 | var basals = [ 33 | {start: new Date("2016-02-18T16:30:00-08:00").getTime(), absolute: 0.2}, 34 | {start: new Date("2016-02-18T17:03:00-08:00").getTime(), absolute: 0.3}, 35 | 36 | {start: new Date("2016-02-18T17:25:00-08:00").getTime(), absolute: 0.4}, 37 | {start: new Date("2016-02-18T17:27:31-08:00").getTime(), absolute: 0.5}, 38 | {start: new Date("2016-02-18T17:30:00-08:00").getTime(), absolute: 0.6}, 39 | 40 | {start: new Date("2016-02-18T17:45:00-08:00").getTime(), absolute: 0.7}, 41 | {start: new Date("2016-02-18T17:47:29-08:00").getTime(), absolute: 0.8}, 42 | {start: new Date("2016-02-18T17:50:00-08:00").getTime()}, 43 | ]; 44 | 45 | // the interval around this endTime is 17:45:00 to 17:50:00 46 | var endTime = new Date("2016-02-18T17:47:30-08:00").getTime(); 47 | 48 | var arr = format.basalRateArray(endTime, basals, MAX_SGVS); 49 | expect(arr.slice(0, 20)).to.eql( 50 | [0.8, 0.6, 0.6, 0.6, 0.4, 0.3, 0.3, 0.3, 0.3, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0, 0, 0] 51 | ); 52 | }); 53 | 54 | it('should return an array of zeroes when there is no data', function() { 55 | expectZeroesForGraph( 56 | format.basalRateArray(Date.now(), [], MAX_SGVS) 57 | ); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/trend_arrow_component.c: -------------------------------------------------------------------------------- 1 | #include "layout.h" 2 | #include "staleness.h" 3 | #include "trend_arrow_component.h" 4 | 5 | #define NO_ICON -1 6 | #define TREND_ARROW_WIDTH 25 7 | 8 | // TODO define a "big" and "small" set of icons 9 | const int8_t TREND_ICONS[] = { 10 | NO_ICON, 11 | RESOURCE_ID_ARROW_DOUBLE_UP, 12 | RESOURCE_ID_ARROW_SINGLE_UP, 13 | RESOURCE_ID_ARROW_FORTY_FIVE_UP, 14 | RESOURCE_ID_ARROW_FLAT, 15 | RESOURCE_ID_ARROW_FORTY_FIVE_DOWN, 16 | RESOURCE_ID_ARROW_SINGLE_DOWN, 17 | RESOURCE_ID_ARROW_DOUBLE_DOWN, 18 | NO_ICON, 19 | NO_ICON 20 | }; 21 | 22 | uint8_t trend_arrow_component_width() { 23 | return TREND_ARROW_WIDTH; 24 | } 25 | 26 | uint8_t trend_arrow_component_height() { 27 | return TREND_ARROW_WIDTH; 28 | } 29 | 30 | TrendArrowComponent* trend_arrow_component_create(Layer *parent, int16_t x, int16_t y) { 31 | BitmapLayer *icon_layer = bitmap_layer_create(GRect(x, y, TREND_ARROW_WIDTH, TREND_ARROW_WIDTH)); 32 | bitmap_layer_set_compositing_mode(icon_layer, element_comp_op(parent)); 33 | layer_set_hidden(bitmap_layer_get_layer(icon_layer), true); 34 | layer_add_child(parent, bitmap_layer_get_layer(icon_layer)); 35 | 36 | TrendArrowComponent *c = malloc(sizeof(TrendArrowComponent)); 37 | c->icon_layer = icon_layer; 38 | c->icon_bitmap = NULL; 39 | c->last_trend = -1; 40 | return c; 41 | } 42 | 43 | void trend_arrow_component_destroy(TrendArrowComponent *c) { 44 | if (c->icon_bitmap != NULL) { 45 | gbitmap_destroy(c->icon_bitmap); 46 | } 47 | bitmap_layer_destroy(c->icon_layer); 48 | free(c); 49 | } 50 | 51 | void trend_arrow_component_update(TrendArrowComponent *c, DataMessage *data) { 52 | if (sgv_graph_padding() > 0) { 53 | c->last_trend = -1; 54 | layer_set_hidden(bitmap_layer_get_layer(c->icon_layer), true); 55 | return; 56 | } 57 | 58 | if (data->trend == c->last_trend) { 59 | return; 60 | } 61 | c->last_trend = data->trend; 62 | 63 | if (TREND_ICONS[data->trend] == NO_ICON) { 64 | layer_set_hidden(bitmap_layer_get_layer(c->icon_layer), true); 65 | } else { 66 | layer_set_hidden(bitmap_layer_get_layer(c->icon_layer), false); 67 | if (c->icon_bitmap != NULL) { 68 | gbitmap_destroy(c->icon_bitmap); 69 | } 70 | c->icon_bitmap = gbitmap_create_with_resource(TREND_ICONS[data->trend]); 71 | bitmap_layer_set_bitmap(c->icon_layer, c->icon_bitmap); 72 | } 73 | } 74 | 75 | void trend_arrow_component_reposition(TrendArrowComponent *c, int16_t x, int16_t y) { 76 | GRect frame = layer_get_frame(bitmap_layer_get_layer(c->icon_layer)); 77 | layer_set_frame( 78 | bitmap_layer_get_layer(c->icon_layer), 79 | GRect(x, y, frame.size.w, frame.size.h) 80 | ); 81 | } 82 | 83 | bool trend_arrow_component_hidden(TrendArrowComponent *c) { 84 | return layer_get_hidden(bitmap_layer_get_layer(c->icon_layer)); 85 | } 86 | -------------------------------------------------------------------------------- /test/js/test_points.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | /* globals describe, it */ 3 | "use strict"; 4 | 5 | var expect = require('expect.js'); 6 | 7 | var c = require('../../src/js/constants.json'); 8 | var points = require('../../src/js/points')(c); 9 | 10 | describe('points', function() { 11 | 12 | describe('computeGraphWidth', function() { 13 | 14 | it('should return the correct value for a 100%-width graph', function() { 15 | var layout = {elements: [{el: c.ELEMENTS.indexOf('GRAPH_ELEMENT'), width: 100}]}; 16 | expect(points.computeGraphWidth(layout)).to.be(144); 17 | }); 18 | 19 | it('should return the correct value for a 75%-width graph', function() { 20 | var layout = {elements: [{el: c.ELEMENTS.indexOf('GRAPH_ELEMENT'), width: 75}]}; 21 | expect(points.computeGraphWidth(layout)).to.be(108); 22 | }); 23 | 24 | it('should return 0 when there is no graph', function() { 25 | var layout = {elements: []}; 26 | expect(points.computeGraphWidth(layout)).to.be(0); 27 | }); 28 | 29 | }); 30 | 31 | describe('computeVisiblePoints', function() { 32 | 33 | it('should return the number of 9-px points visible with a 4-px margin', function() { 34 | var config = { 35 | pointShape: 'circle', 36 | pointWidth: 9, 37 | pointRectHeight: 9, 38 | pointMargin: 4, 39 | pointRightMargin: 4, 40 | }; 41 | expect(points.computeVisiblePoints(144, config)).to.be(11); 42 | config.pointRightMargin = 5; 43 | expect(points.computeVisiblePoints(144, config)).to.be(11); 44 | config.pointRightMargin = 6; 45 | expect(points.computeVisiblePoints(144, config)).to.be(10); 46 | }); 47 | 48 | it('should return the number of 5-px points visible with a 4-px margin', function() { 49 | var config = { 50 | pointShape: 'rectangle', 51 | pointWidth: 5, 52 | pointRectHeight: 5, 53 | pointMargin: 4, 54 | pointRightMargin: 0, 55 | }; 56 | expect(points.computeVisiblePoints(144, config)).to.be(16); 57 | config.pointRightMargin = 4; 58 | expect(points.computeVisiblePoints(144, config)).to.be(16); 59 | config.pointRightMargin = 5; 60 | expect(points.computeVisiblePoints(144, config)).to.be(15); 61 | config.pointRightMargin = 13; 62 | expect(points.computeVisiblePoints(144, config)).to.be(15); 63 | config.pointRightMargin = 14; 64 | expect(points.computeVisiblePoints(144, config)).to.be(14); 65 | }); 66 | 67 | it('should work with a 108-px screen width', function() { 68 | var config = { 69 | pointShape: 'rectangle', 70 | pointWidth: 4, 71 | pointRectHeight: 5, 72 | pointMargin: 1, 73 | pointRightMargin: 0, 74 | }; 75 | expect(points.computeVisiblePoints(108, config)).to.be(21); 76 | }); 77 | 78 | it('should handle negative point margins', function() { 79 | var config = { 80 | pointShape: 'rectangle', 81 | pointWidth: 3, 82 | pointRectHeight: 3, 83 | pointMargin: -1, 84 | pointRightMargin: 0, 85 | }; 86 | expect(points.computeVisiblePoints(108, config)).to.be(55); 87 | }); 88 | 89 | }); 90 | 91 | }); 92 | -------------------------------------------------------------------------------- /test/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mock Nightscout server. Two modes for data source: 3 | 1. the values received in a POST to /set-sgv, /set-treatments, etc. (default) 4 | 2. the values defined on a screenshot test case, specified by --test-class 5 | """ 6 | 7 | import argparse 8 | import json 9 | import os 10 | import sys 11 | import urllib 12 | 13 | import requests 14 | from flask import Flask, request 15 | from werkzeug.contrib.cache import SimpleCache 16 | from werkzeug.exceptions import NotFound 17 | 18 | import test_screenshots 19 | 20 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter) 21 | parser.add_argument('--port') 22 | parser.add_argument('--test-class') 23 | parser.add_argument('--debug', action='store_true') 24 | args, _ = parser.parse_known_args() 25 | 26 | port = int(args.port or os.environ.get('MOCK_SERVER_PORT') or 0) 27 | if port == 0: 28 | print "Port must be set via MOCK_SERVER_PORT or --port" 29 | sys.exit() 30 | 31 | COLLECTIONS = ['entries', 'treatments', 'profile', 'devicestatus'] 32 | 33 | cache = SimpleCache(default_timeout=999999) 34 | for coll in COLLECTIONS: 35 | cache.set(coll, '[]') 36 | 37 | app = Flask(__name__) 38 | 39 | def _get_post_json(request): 40 | return json.loads(request.data or request.form.keys()[0]) 41 | 42 | @app.route('/api/v1/.json') 43 | def get_collection(coll): 44 | elements = _collection_from_test(coll, args.test_class) if args.test_class else _collection_from_cache(coll) 45 | if coll == 'treatments': 46 | return json.dumps(_filter_treatments(elements, request.args)) 47 | elif elements is not None: 48 | return json.dumps(elements) 49 | else: 50 | raise NotFound 51 | 52 | def _collection_from_cache(coll): 53 | return cache.get(coll) if coll in COLLECTIONS else None 54 | 55 | def _collection_from_test(coll, test_class_name): 56 | # XXX this is very fragile, but for now makes iterating much faster 57 | reload(test_screenshots) 58 | if coll == 'entries': 59 | return getattr(test_screenshots, test_class_name)().sgvs() 60 | elif coll == 'treatments': 61 | return getattr(test_screenshots, test_class_name)().treatments() 62 | elif coll == 'profile': 63 | return getattr(test_screenshots, test_class_name)().profile() 64 | elif coll == 'devicestatus': 65 | return getattr(test_screenshots, test_class_name)().devicestatus() 66 | else: 67 | return None 68 | 69 | def _filter_treatments(treatments, query): 70 | # XXX hard-coding the queries performed by the JS 71 | if query.get('find[eventType]') == 'Temp Basal': 72 | return [t for t in treatments if 'duration' in t] 73 | elif query.get('find[insulin][$exists]'): 74 | return [t for t in treatments if 'insulin' in t] 75 | else: 76 | return treatments 77 | 78 | @app.route('/set-', methods=['post']) 79 | def set_collection(coll): 80 | if coll in COLLECTIONS: 81 | cache.set(coll, _get_post_json(request)) 82 | return '' 83 | else: 84 | raise NotFound 85 | 86 | @app.route('/api/v1/entries/sgv.json') 87 | def get_sgv(): 88 | return get_collection('entries') 89 | 90 | @app.route('/set-sgv', methods=['post']) 91 | def set_sgv(): 92 | return set_collection('entries') 93 | 94 | if __name__ == "__main__": 95 | app.run(port=port, debug=args.debug) 96 | -------------------------------------------------------------------------------- /src/format.c: -------------------------------------------------------------------------------- 1 | #include "format.h" 2 | #include "preferences.h" 3 | 4 | void format_bg(char* buffer, char buf_size, int16_t mgdl, bool is_delta, bool use_mmol) { 5 | if (!is_delta && mgdl == 0) { 6 | strcpy(buffer, "-"); 7 | return; 8 | } 9 | char* plus_minus; 10 | if (is_delta) { 11 | plus_minus = mgdl >= 0 ? "+" : "-"; 12 | mgdl = (mgdl < 0 ? -1 : 1) * mgdl; 13 | } else { 14 | plus_minus = ""; 15 | } 16 | 17 | if (use_mmol) { 18 | // Compute (mgdl / 18.0) without floating-point math 19 | int a = mgdl / 18; 20 | int b = 10 * (mgdl - a * 18) / 18; 21 | if (10 * mgdl - 180 * a - 18 * b >= 9) { 22 | b += 1; 23 | } 24 | if (a == 0 && b == 0) { 25 | // e.g. "-1 mg/dL" == "+0.0 mmol/L" 26 | plus_minus = "+"; 27 | } 28 | snprintf(buffer, buf_size, "%s%d.%d", plus_minus, a, b); 29 | } else { 30 | snprintf(buffer, buf_size, "%s%d", plus_minus, mgdl); 31 | } 32 | } 33 | 34 | void format_recency(char* buf, uint16_t buf_size, int32_t seconds) { 35 | int32_t minutes = seconds / 60; 36 | if (seconds - minutes * 60 >= 30) { 37 | minutes += 1; 38 | } 39 | int32_t hours = minutes / 60; 40 | if (minutes < 60) { 41 | snprintf(buf, buf_size, "%d", (int)minutes); 42 | } else if (hours < 10 && minutes % 60 > 0) { 43 | snprintf(buf, buf_size, "%dh%d", (int)hours, (int)(minutes - 60 * hours)); 44 | } else if (hours < 100) { 45 | snprintf(buf, buf_size, "%dh", (int)hours); 46 | } else { 47 | strcpy(buf, "!"); 48 | } 49 | } 50 | 51 | void format_status_bar_text(char* buffer, uint16_t buf_size, DataMessage *d) { 52 | int32_t recency = time(NULL) - d->received_at + d->status_recency; 53 | int32_t minutes = recency / 60; 54 | if (recency - minutes * 60 >= 30) { 55 | minutes += 1; 56 | } 57 | 58 | if (d->status_recency == -1 || (get_prefs()->status_min_recency_to_show_minutes > 0 && minutes <= get_prefs()->status_min_recency_to_show_minutes)) { 59 | 60 | strcpy(buffer, d->status_text); 61 | 62 | } else if (minutes > get_prefs()->status_max_age_minutes) { 63 | 64 | strcpy(buffer, "-"); 65 | 66 | } else { 67 | 68 | static char recency_str[16]; 69 | format_recency(recency_str, 16, recency); 70 | 71 | switch(get_prefs()->status_recency_format) { 72 | case STATUS_RECENCY_FORMAT_PAREN_LEFT: 73 | snprintf(buffer, buf_size, "(%s) %s", recency_str, d->status_text); 74 | break; 75 | case STATUS_RECENCY_FORMAT_BRACKET_LEFT: 76 | snprintf(buffer, buf_size, "[%s] %s", recency_str, d->status_text); 77 | break; 78 | case STATUS_RECENCY_FORMAT_COLON_LEFT: 79 | snprintf(buffer, buf_size, "%s: %s", recency_str, d->status_text); 80 | break; 81 | case STATUS_RECENCY_FORMAT_CLOSE_PAREN_LEFT: 82 | snprintf(buffer, buf_size, "%s) %s", recency_str, d->status_text); 83 | break; 84 | case STATUS_RECENCY_FORMAT_PLAIN_LEFT: 85 | snprintf(buffer, buf_size, "%s %s", recency_str, d->status_text); 86 | break; 87 | case STATUS_RECENCY_FORMAT_PAREN_RIGHT: 88 | snprintf(buffer, buf_size, "%s (%s)", d->status_text, recency_str); 89 | break; 90 | case STATUS_RECENCY_FORMAT_BRACKET_RIGHT: 91 | snprintf(buffer, buf_size, "%s [%s]", d->status_text, recency_str); 92 | break; 93 | default: 94 | break; 95 | } 96 | 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /make_inline_config.py: -------------------------------------------------------------------------------- 1 | # Transform config/index.html so that all of its image, font, CSS, and script 2 | # dependencies are inlined, and convert the result into a data URI. 3 | # 4 | # This makes it possible to view the Urchin configuration page without an 5 | # internet connection. Also, since the config page is bundled with the app 6 | # (rather than hosted on the web), its available settings always match the 7 | # features of the current version. 8 | # 9 | # See https://github.com/pebble/clay for a more robust implementation of offline 10 | # configuration pages. 11 | 12 | import base64 13 | import json 14 | import os 15 | # Use re instead of something like PyQuery so that the hell of installing lxml 16 | # is not a prerequisite for building the watchface 17 | import re 18 | import subprocess 19 | 20 | def call_minify(command_str, stdin, filename): 21 | parts = command_str.split(' ') 22 | try: 23 | proc = subprocess.Popen(parts, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 24 | except Exception, e: 25 | raise Exception("Command failed: {}: {}".format(command_str, e)) 26 | out, err = proc.communicate(input=stdin) 27 | if err: 28 | print command_str 29 | raise Exception('{}: {}: failed with return code {}'.format(command_str, filename, err)) 30 | else: 31 | print '{}: {}: {} -> {} bytes'.format(parts[0], filename, len(stdin), len(out)) 32 | return out 33 | 34 | def make_inline_config(task, html_file_node): 35 | config_dir = html_file_node.parent.abspath() 36 | 37 | html = html_file_node.read() 38 | css_tags = re.findall('(]* href="([^"]+)">)', html, re.I) 39 | assert len(css_tags) == 1 40 | 41 | css_filename = os.path.join(config_dir, css_tags[0][1]) 42 | css_dir = os.path.dirname(css_filename) 43 | 44 | css = open(css_filename).read() 45 | css = re.sub('\s*([{}:;])\s*', lambda match: match.group(1), css) 46 | 47 | urls = re.findall('(url\(([^)]+)\))', css, re.I) 48 | assert len(urls) > 0 49 | for url in urls: 50 | filename = url[1] 51 | filename = re.sub('(^"|"$)', '', filename) 52 | filename = re.sub("(^'|'$)", '', filename) 53 | assert filename.endswith('.png') 54 | mime_type = 'image/png' 55 | encoded = base64.b64encode(open(os.path.join(css_dir, filename), "rb").read()) 56 | css = css.replace( 57 | url[0], 58 | 'url(data:{};base64,{})'.format(mime_type, encoded) 59 | ) 60 | 61 | minified_css = call_minify('./node_modules/clean-css/bin/cleancss', css, os.path.relpath(css_filename)) 62 | 63 | html = html.replace( 64 | css_tags[0][0], 65 | ''.format(minified_css) 66 | ) 67 | 68 | js_tags = re.findall('(]* src="([^"]+)">)', html, re.I) 69 | assert len(js_tags) > 0 70 | for js_tag in js_tags: 71 | js_filename = os.path.join(config_dir, js_tag[1]) 72 | js = open(js_filename).read() 73 | minified_js = call_minify('./node_modules/uglify-js/bin/uglifyjs', js, os.path.relpath(js_filename)) 74 | html = html.replace( 75 | js_tag[0], 76 | ''.format(minified_js) 77 | ) 78 | 79 | minified_html = call_minify('./node_modules/html-minifier/cli.js --remove-comments --remove-attribute-quotes', html, html_file_node.relpath()) 80 | 81 | return json.dumps({'configPage': minified_html}) 82 | -------------------------------------------------------------------------------- /src/js/status_formatters.js: -------------------------------------------------------------------------------- 1 | /* global module, window */ 2 | 3 | var translations = { 4 | 'English': { 5 | 'month': ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], 6 | 'monthShort': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], 7 | 'day': ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], 8 | 'dayShort': ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], 9 | }, 10 | // TODO 11 | }; 12 | 13 | function formatDate(format) { 14 | var now = new Date(); 15 | var year = 1900 + now.getYear(); 16 | var month = now.getMonth(); 17 | var date = now.getDate(); 18 | var day = now.getDay(); 19 | 20 | var yyyy = year.toString(); 21 | var yy = year.toString().slice(-2); 22 | 23 | var language = 'English'; 24 | 25 | var mmmm = translations[language]['month'][month]; 26 | var mmm = translations[language]['monthShort'][month]; 27 | var mm = ('0' + (month + 1)).slice(-2); 28 | var m = (month + 1).toString(); 29 | 30 | var dddd = translations[language]['day'][day]; 31 | var ddd = translations[language]['dayShort'][day]; 32 | 33 | var dd = ('0' + date).slice(-2); 34 | var d = date.toString(); 35 | 36 | return format 37 | .replace('yyyy', '%YYYY%') 38 | .replace('yy', '%YY%') 39 | .replace('mmmm', '%MMMM%') 40 | .replace('mmm', '%MMM%') 41 | .replace('mm', '%MM%') 42 | .replace('m', '%M%') 43 | .replace('dddd', '%DDDD%') 44 | .replace('ddd', '%DDD%') 45 | .replace('dd', '%DD%') 46 | .replace('d', '%D%') 47 | .replace('%YYYY%', yyyy) 48 | .replace('%YY%', yy) 49 | .replace('%MMMM%', mmmm) 50 | .replace('%MMM%', mmm) 51 | .replace('%MM%', mm) 52 | .replace('%M%', m) 53 | .replace('%DDDD%', dddd) 54 | .replace('%DDD%', ddd) 55 | .replace('%DD%', dd) 56 | .replace('%D%', d); 57 | } 58 | 59 | function formatLoopStatus(props, format, convertToMmol) { 60 | if (convertToMmol && props.evbg !== undefined) { 61 | props.evbg = (props.evbg / 18.0).toFixed(1); 62 | } 63 | 64 | if (props.temprate !== undefined) { 65 | props.temprate = props.temprate.toFixed(2); 66 | } 67 | 68 | var units = { 69 | 'evbg': (convertToMmol ? 'mmol/L' : 'mg/dL'), 70 | 'iob': 'U', 71 | 'cob': 'g', 72 | 'temprate': 'U/h', 73 | 'pumpvoltage': 'v', 74 | 'pumpbat': '%', 75 | 'reservoir': 'U', 76 | 'phonebat': '%', 77 | }; 78 | 79 | var text = format; 80 | 81 | text = text.replace(/\\n/g, '\n'); 82 | ['evbg', 'iob', 'cob', 'temprate', 'pumpvoltage', 'pumpbat', 'reservoir', 'phonebat'].forEach(function(key) { 83 | if (props[key] !== undefined) { 84 | text = text 85 | .replace(new RegExp(key + 'u', 'i'), props[key] + units[key]) 86 | .replace(new RegExp(key + '_u', 'i'), props[key] + ' ' + units[key]) 87 | .replace(new RegExp(key, 'i'), props[key]); 88 | } else { 89 | text = text.replace(new RegExp(key + '(u|_u)?', 'i'), '%UNDEF%'); 90 | } 91 | }); 92 | 93 | text = text 94 | .replace(/ *%UNDEF%/g, '') 95 | .replace(/ +\n +/g, '\n') 96 | .replace(/(^ +| +$)/g, ''); 97 | 98 | return text; 99 | } 100 | 101 | var formatters = { 102 | formatDate: formatDate, 103 | formatLoopStatus: formatLoopStatus, 104 | }; 105 | 106 | // TODO: include this in the config page in a better way 107 | if (typeof(module) !== 'undefined') { 108 | module.exports = formatters; 109 | } 110 | if (typeof(window) !== 'undefined') { 111 | window.statusFormatters = formatters; 112 | } 113 | -------------------------------------------------------------------------------- /src/staleness.c: -------------------------------------------------------------------------------- 1 | #include "app_messages.h" 2 | #include "config.h" 3 | #include "staleness.h" 4 | 5 | #define GRAPH_INTERVAL_SIZE_SECONDS (5*60) 6 | 7 | static bool ever_seen_request_complete = false; 8 | static bool ever_had_phone_contact = false; 9 | static bool ever_received_data = false; 10 | static time_t app_start_time; 11 | static time_t last_phone_contact; 12 | static time_t last_successful_phone_contact; 13 | static uint32_t last_data_staleness_wrt_phone; 14 | 15 | static uint32_t phone_to_pebble_staleness() { 16 | return time(NULL) - last_phone_contact; 17 | } 18 | 19 | static uint32_t web_to_phone_staleness() { 20 | return last_phone_contact - last_successful_phone_contact; 21 | } 22 | 23 | static uint32_t rig_to_web_staleness() { 24 | return last_data_staleness_wrt_phone; 25 | } 26 | 27 | static uint32_t total_data_staleness() { 28 | return rig_to_web_staleness() + web_to_phone_staleness() + phone_to_pebble_staleness(); 29 | } 30 | 31 | uint32_t sgv_graph_padding() { 32 | uint32_t staleness = total_data_staleness(); 33 | uint32_t padding = staleness / GRAPH_INTERVAL_SIZE_SECONDS; 34 | if (padding == 1 && staleness < GRAPH_INTERVAL_SIZE_SECONDS + GRAPH_STALENESS_GRACE_PERIOD_SECONDS) { 35 | padding = 0; 36 | } 37 | if (padding > GRAPH_MAX_SGV_COUNT) { 38 | padding = GRAPH_MAX_SGV_COUNT; 39 | } 40 | return padding; 41 | } 42 | 43 | ConnectionIssue connection_issue() { 44 | if (!ever_seen_request_complete) { 45 | // Haven't seen a request time out yet 46 | return (ConnectionIssue) { 47 | .reason = CONNECTION_ISSUE_NONE, 48 | .staleness = 0, 49 | }; 50 | } else if (ever_seen_request_complete && !ever_had_phone_contact) { 51 | // No phone contact and a request to the phone has timed out 52 | return (ConnectionIssue) { 53 | .reason = CONNECTION_ISSUE_BLUETOOTH, 54 | .staleness = time(NULL) - app_start_time, 55 | }; 56 | } else if (ever_seen_request_complete && ever_had_phone_contact && !ever_received_data) { 57 | // Have heard from the phone but it has never successfully fetched data 58 | return (ConnectionIssue) { 59 | .reason = CONNECTION_ISSUE_NETWORK, 60 | .staleness = time(NULL) - app_start_time, 61 | }; 62 | } 63 | 64 | if (sgv_graph_padding() > 0) { 65 | if (phone_to_pebble_staleness() > SGV_UPDATE_FREQUENCY_SECONDS) { 66 | return (ConnectionIssue) { 67 | .reason = CONNECTION_ISSUE_BLUETOOTH, 68 | .staleness = phone_to_pebble_staleness(), 69 | }; 70 | } else if (web_to_phone_staleness() > SGV_UPDATE_FREQUENCY_SECONDS) { 71 | return (ConnectionIssue) { 72 | .reason = CONNECTION_ISSUE_NETWORK, 73 | .staleness = web_to_phone_staleness(), 74 | }; 75 | } else if (rig_to_web_staleness() > SGV_UPDATE_FREQUENCY_SECONDS) { 76 | return (ConnectionIssue) { 77 | .reason = CONNECTION_ISSUE_RIG, 78 | .staleness = rig_to_web_staleness(), 79 | }; 80 | } 81 | } 82 | 83 | return (ConnectionIssue) { 84 | .reason = CONNECTION_ISSUE_NONE, 85 | .staleness = 0, 86 | }; 87 | } 88 | 89 | void init_staleness() { 90 | app_start_time = time(NULL); 91 | } 92 | 93 | void staleness_on_request_state_changed(RequestState state) { 94 | if (state != REQUEST_STATE_WAITING) { 95 | ever_seen_request_complete = true; 96 | } 97 | if (state == REQUEST_STATE_SUCCESS || state == REQUEST_STATE_BAD_APP_MESSAGE || state == REQUEST_STATE_FETCH_ERROR) { 98 | ever_had_phone_contact = true; 99 | last_phone_contact = time(NULL); 100 | } 101 | } 102 | 103 | void staleness_on_data_received(int32_t recency) { 104 | ever_received_data = true; 105 | last_successful_phone_contact = time(NULL); 106 | last_data_staleness_wrt_phone = recency; 107 | } 108 | -------------------------------------------------------------------------------- /src/bg_row_element.c: -------------------------------------------------------------------------------- 1 | #include "bg_row_element.h" 2 | #include "fonts.h" 3 | #include "layout.h" 4 | #include "text_updates.h" 5 | 6 | #define BG_TREND_PADDING 8 7 | #define TREND_DELTA_PADDING 5 8 | #define MISSING_TREND_BG_DELTA_PADDING 10 9 | 10 | static void bg_row_element_rearrange(BGRowElement *el) { 11 | GSize bg_size = text_layer_get_content_size(el->bg_text); 12 | GSize delta_size = text_layer_get_content_size(el->delta_text); 13 | uint8_t total_width = bg_size.w \ 14 | + (trend_arrow_component_hidden(el->trend) ? 0 : BG_TREND_PADDING + trend_arrow_component_width()) \ 15 | + (layer_get_hidden(text_layer_get_layer(el->delta_text)) ? 0 : TREND_DELTA_PADDING + delta_size.w); 16 | int16_t bg_x = (el->parent_size.w - total_width) / 2; 17 | 18 | GRect bg_frame = layer_get_frame(text_layer_get_layer(el->bg_text)); 19 | layer_set_frame(text_layer_get_layer(el->bg_text), GRect( 20 | bg_x, 21 | bg_frame.origin.y, 22 | bg_frame.size.w, 23 | bg_frame.size.h 24 | )); 25 | 26 | trend_arrow_component_reposition( 27 | el->trend, 28 | bg_x + bg_size.w + BG_TREND_PADDING, 29 | (el->parent_size.h - trend_arrow_component_height()) / 2 30 | ); 31 | 32 | GRect delta_frame = layer_get_frame(text_layer_get_layer(el->delta_text)); 33 | int16_t delta_x = bg_x + bg_size.w \ 34 | + (trend_arrow_component_hidden(el->trend) \ 35 | ? MISSING_TREND_BG_DELTA_PADDING \ 36 | : BG_TREND_PADDING + trend_arrow_component_width() + TREND_DELTA_PADDING); 37 | layer_set_frame(text_layer_get_layer(el->delta_text), GRect( 38 | delta_x, 39 | delta_frame.origin.y, 40 | delta_frame.size.w, 41 | delta_frame.size.h 42 | )); 43 | } 44 | 45 | BGRowElement* bg_row_element_create(Layer *parent) { 46 | GRect bounds = element_get_bounds(parent); 47 | 48 | FontChoice bg_font = get_font(FONT_34_NUMBERS); 49 | TextLayer *bg_text = add_text_layer( 50 | parent, 51 | GRect( 52 | 0, 53 | (bounds.size.h - bg_font.height) / 2 - bg_font.padding_top, 54 | bounds.size.w, 55 | bg_font.height + bg_font.padding_top + bg_font.padding_bottom 56 | ), 57 | fonts_get_system_font(bg_font.key), 58 | element_fg(parent), 59 | GTextAlignmentLeft 60 | ); 61 | 62 | TrendArrowComponent *trend = trend_arrow_component_create( 63 | parent, 64 | 0, // set by bg_row_element_rearrange 65 | (bounds.size.h - trend_arrow_component_height()) / 2 66 | ); 67 | 68 | FontChoice delta_font = get_font(FONT_28_BOLD); 69 | TextLayer *delta_text = add_text_layer( 70 | parent, 71 | GRect( 72 | 0, // set by bg_row_element_rearrange 73 | (bounds.size.h - delta_font.height) / 2 - delta_font.padding_top, 74 | bounds.size.w, 75 | delta_font.height + delta_font.padding_top + delta_font.padding_bottom 76 | ), 77 | fonts_get_system_font(delta_font.key), 78 | element_fg(parent), 79 | GTextAlignmentLeft 80 | ); 81 | 82 | BGRowElement *el = malloc(sizeof(BGRowElement)); 83 | el->parent_size = bounds.size; 84 | el->bg_text = bg_text; 85 | el->trend = trend; 86 | el->delta_text = delta_text; 87 | return el; 88 | } 89 | 90 | void bg_row_element_destroy(BGRowElement *el) { 91 | text_layer_destroy(el->bg_text); 92 | trend_arrow_component_destroy(el->trend); 93 | text_layer_destroy(el->delta_text); 94 | free(el); 95 | } 96 | 97 | void bg_row_element_update(BGRowElement *el, DataMessage *data) { 98 | last_bg_text_layer_update(el->bg_text, data); 99 | trend_arrow_component_update(el->trend, data); 100 | delta_text_layer_update(el->delta_text, data); 101 | layer_set_hidden( 102 | text_layer_get_layer(el->delta_text), 103 | strcmp("-", text_layer_get_text(el->delta_text)) == 0 104 | ); 105 | bg_row_element_rearrange(el); 106 | } 107 | 108 | void bg_row_element_tick(BGRowElement *el) {} 109 | -------------------------------------------------------------------------------- /src/preferences.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #define PERSIST_KEY_VERSION 0 6 | #define PERSIST_KEY_PREFERENCES_OBJECT 1 7 | 8 | #define PREFERENCES_SCHEMA_VERSION 14 9 | 10 | enum { 11 | ALIGN_LEFT, 12 | ALIGN_CENTER, 13 | ALIGN_RIGHT, 14 | }; 15 | 16 | enum { 17 | BATTERY_LOC_NONE, 18 | BATTERY_LOC_STATUS_RIGHT, 19 | BATTERY_LOC_TIME_TOP_LEFT, 20 | BATTERY_LOC_TIME_TOP_RIGHT, 21 | BATTERY_LOC_TIME_BOTTOM_LEFT, 22 | BATTERY_LOC_TIME_BOTTOM_RIGHT, 23 | }; 24 | 25 | enum { 26 | CONN_STATUS_LOC_NONE, 27 | CONN_STATUS_LOC_GRAPH_TOP_LEFT, 28 | CONN_STATUS_LOC_GRAPH_BOTTOM_LEFT, 29 | }; 30 | 31 | enum { 32 | RECENCY_LOC_NONE, 33 | RECENCY_LOC_GRAPH_TOP_LEFT, 34 | RECENCY_LOC_GRAPH_BOTTOM_LEFT, 35 | RECENCY_LOC_STATUS_TOP_RIGHT, 36 | RECENCY_LOC_STATUS_BOTTOM_RIGHT, 37 | RECENCY_LOC_TIME_TOP_LEFT, 38 | RECENCY_LOC_TIME_TOP_RIGHT, 39 | RECENCY_LOC_TIME_BOTTOM_LEFT, 40 | RECENCY_LOC_TIME_BOTTOM_RIGHT, 41 | }; 42 | 43 | enum { 44 | RECENCY_STYLE_SMALL_NO_CIRCLE, 45 | RECENCY_STYLE_MEDIUM_PIE, 46 | RECENCY_STYLE_MEDIUM_RING, 47 | RECENCY_STYLE_MEDIUM_NO_CIRCLE, 48 | RECENCY_STYLE_LARGE_PIE, 49 | RECENCY_STYLE_LARGE_RING, 50 | RECENCY_STYLE_LARGE_NO_CIRCLE, 51 | }; 52 | 53 | enum { 54 | POINT_SHAPE_RECTANGLE, 55 | POINT_SHAPE_CIRCLE, 56 | }; 57 | 58 | // The order here should match constants.PROPERTIES. 59 | enum { 60 | ELEMENT_TYPE, 61 | ELEMENT_ENABLED, 62 | ELEMENT_WIDTH, 63 | ELEMENT_HEIGHT, 64 | ELEMENT_BLACK, 65 | ELEMENT_BOTTOM, 66 | ELEMENT_RIGHT, 67 | NUM_ELEMENT_PROPERTIES, 68 | }; 69 | 70 | enum { 71 | GRAPH_ELEMENT, 72 | SIDEBAR_ELEMENT, 73 | STATUS_BAR_ELEMENT, 74 | TIME_AREA_ELEMENT, 75 | BG_ROW_ELEMENT, 76 | MAX_LAYOUT_ELEMENTS, 77 | }; 78 | 79 | enum { 80 | COLOR_KEY_POINT_DEFAULT, 81 | COLOR_KEY_POINT_HIGH, 82 | COLOR_KEY_POINT_LOW, 83 | COLOR_KEY_PLOT_LINE, 84 | COLOR_KEY_RECENCY_CIRCLE, 85 | COLOR_KEY_RECENCY_TEXT, 86 | COLOR_KEY_PREDICT_DEFAULT, 87 | COLOR_KEY_PREDICT_HIGH, 88 | COLOR_KEY_PREDICT_LOW, 89 | NUM_COLOR_KEYS, 90 | }; 91 | 92 | enum { 93 | STATUS_RECENCY_FORMAT_PAREN_LEFT, 94 | STATUS_RECENCY_FORMAT_BRACKET_LEFT, 95 | STATUS_RECENCY_FORMAT_COLON_LEFT, 96 | STATUS_RECENCY_FORMAT_CLOSE_PAREN_LEFT, 97 | STATUS_RECENCY_FORMAT_PLAIN_LEFT, 98 | STATUS_RECENCY_FORMAT_PAREN_RIGHT, 99 | STATUS_RECENCY_FORMAT_BRACKET_RIGHT, 100 | }; 101 | 102 | typedef struct __attribute__((__packed__)) ElementConfig { 103 | unsigned int el:3; 104 | uint8_t w; 105 | uint8_t h; 106 | bool black; 107 | bool bottom; 108 | bool right; 109 | } ElementConfig; 110 | 111 | typedef struct __attribute__((__packed__)) Preferences { 112 | bool mmol; 113 | uint16_t top_of_graph; 114 | uint16_t top_of_range; 115 | uint8_t bottom_of_range; 116 | uint8_t bottom_of_graph; 117 | uint8_t h_gridlines; 118 | bool battery_as_number; 119 | bool basal_graph; 120 | unsigned int basal_height:5; 121 | bool update_every_minute; 122 | unsigned int time_align:2; 123 | unsigned int battery_loc:3; 124 | unsigned int conn_status_loc:2; 125 | unsigned int recency_loc:4; 126 | unsigned int recency_style:3; 127 | unsigned int point_shape:2; 128 | unsigned int point_rect_height:5; 129 | unsigned int point_width:5; 130 | int8_t point_margin; 131 | unsigned int point_right_margin:5; 132 | bool plot_line; 133 | unsigned int plot_line_width:4; 134 | bool plot_line_is_custom_color; 135 | unsigned int num_elements:3; 136 | ElementConfig elements[MAX_LAYOUT_ELEMENTS]; 137 | GColor colors[NUM_COLOR_KEYS]; 138 | uint8_t status_min_recency_to_show_minutes; 139 | uint16_t status_max_age_minutes; 140 | unsigned int status_recency_format:3; 141 | } Preferences; 142 | 143 | void init_prefs(); 144 | void deinit_prefs(); 145 | Preferences* get_prefs(); 146 | void set_prefs(DictionaryIterator *data); 147 | -------------------------------------------------------------------------------- /src/status_bar_element.c: -------------------------------------------------------------------------------- 1 | #include "fonts.h" 2 | #include "format.h" 3 | #include "layout.h" 4 | #include "preferences.h" 5 | #include "staleness.h" 6 | #include "status_bar_element.h" 7 | 8 | #define SM_TEXT_MARGIN (2) 9 | 10 | StatusBarElement* status_bar_element_create(Layer *parent) { 11 | GRect bounds = element_get_bounds(parent); 12 | 13 | FontChoice font = get_font(FONT_18_BOLD); 14 | 15 | int16_t text_y, height; 16 | if (bounds.size.h <= font.height * 2 + font.padding_top + font.padding_bottom) { 17 | // vertically center text if there is only room for one line 18 | text_y = (bounds.size.h - font.height) / 2 - font.padding_top; 19 | height = font.height + font.padding_top + font.padding_bottom; 20 | } else { 21 | // otherwise take up all the space, with half the default padding 22 | text_y = -1 * font.padding_top / 2; 23 | height = bounds.size.h - text_y; 24 | } 25 | 26 | StatusBarElement *el = malloc(sizeof(StatusBarElement)); 27 | 28 | el->text = add_text_layer( 29 | parent, 30 | GRect( 31 | SM_TEXT_MARGIN, 32 | text_y, 33 | bounds.size.w - SM_TEXT_MARGIN, 34 | height 35 | ), 36 | fonts_get_system_font(font.key), 37 | element_fg(parent), 38 | GTextAlignmentLeft 39 | ); 40 | text_layer_set_overflow_mode(el->text, GTextOverflowModeWordWrap); 41 | 42 | int8_t lines; 43 | 44 | el->battery = NULL; 45 | if (get_prefs()->battery_loc == BATTERY_LOC_STATUS_RIGHT) { 46 | // align the battery to the middle of the lowest line of text 47 | lines = (bounds.size.h - text_y) / (font.height + font.padding_top); 48 | int8_t battery_y = text_y + (font.height + font.padding_top) * (lines - 1) + font.padding_top + font.height / 2 - battery_component_height() / 2; 49 | // ...unless that places it too close to the bottom 50 | if (battery_y + battery_component_height() - battery_component_vertical_padding() > bounds.size.h - SM_TEXT_MARGIN) { 51 | battery_y = bounds.size.h - battery_component_height() + battery_component_vertical_padding() - SM_TEXT_MARGIN; 52 | } 53 | 54 | el->battery = battery_component_create(parent, bounds.size.w - battery_component_width() - SM_TEXT_MARGIN, battery_y, true); 55 | } 56 | 57 | el->recency = NULL; 58 | if (get_prefs()->recency_loc == RECENCY_LOC_STATUS_TOP_RIGHT || get_prefs()->recency_loc == RECENCY_LOC_STATUS_BOTTOM_RIGHT) { 59 | if (get_prefs()->recency_loc == RECENCY_LOC_STATUS_TOP_RIGHT) { 60 | lines = 1; 61 | } else { 62 | lines = (bounds.size.h - text_y) / (font.height + font.padding_top); 63 | } 64 | // vertically align with the center of the first/last line of text 65 | int16_t recency_y = text_y + (font.height + font.padding_top) * (lines - 1) + font.padding_top + font.height / 2 - recency_component_height() / 2; 66 | // keep it within the bounds 67 | if (recency_y + recency_component_padding() < 0) { 68 | recency_y = -recency_component_padding(); 69 | } else if (recency_y + recency_component_height() > bounds.size.h) { 70 | recency_y = bounds.size.h - recency_component_height() + recency_component_padding(); 71 | } 72 | 73 | el->recency = recency_component_create(parent, recency_y, true, NULL, NULL); 74 | } 75 | 76 | return el; 77 | } 78 | 79 | void status_bar_element_destroy(StatusBarElement *el) { 80 | text_layer_destroy(el->text); 81 | if (el->battery != NULL) { 82 | battery_component_destroy(el->battery); 83 | } 84 | if (el->recency != NULL) { 85 | recency_component_destroy(el->recency); 86 | } 87 | free(el); 88 | } 89 | 90 | void status_bar_element_update(StatusBarElement *el, DataMessage *data) { 91 | status_bar_element_tick(el); 92 | } 93 | 94 | void status_bar_element_tick(StatusBarElement *el) { 95 | if (last_data_message() == NULL) { 96 | return; 97 | } 98 | static char buffer[STATUS_BAR_MAX_LENGTH + 16]; 99 | format_status_bar_text(buffer, sizeof(buffer), last_data_message()); 100 | text_layer_set_text(el->text, buffer); 101 | 102 | if (el->recency != NULL) { 103 | recency_component_tick(el->recency); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/battery_component.c: -------------------------------------------------------------------------------- 1 | #include "battery_component.h" 2 | #include "fonts.h" 3 | #include "layout.h" 4 | #include "preferences.h" 5 | 6 | #define BATTERY_ICON_WIDTH 24 7 | #define BATTERY_ICON_HEIGHT 22 8 | #define BATTERY_ICON_PADDING 4 9 | #define BATTERY_ICON_TOP_FUDGE 1 10 | 11 | #define BATTERY_TEXT_WIDTH 50 12 | #define BATTERY_FONT FONT_18_BOLD 13 | 14 | // XXX need to keep reference to this for battery_handler 15 | static BatteryComponent *s_component; 16 | 17 | static uint32_t battery_icon_id(BatteryChargeState charge_state) { 18 | if (charge_state.is_charging) { 19 | return RESOURCE_ID_BATTERY_CHARGING; 20 | } else if (charge_state.charge_percent <= 10) { 21 | return RESOURCE_ID_BATTERY_10; 22 | } else if (charge_state.charge_percent <= 20) { 23 | return RESOURCE_ID_BATTERY_25; 24 | } else if (charge_state.charge_percent <= 50) { 25 | return RESOURCE_ID_BATTERY_50; 26 | } else if (charge_state.charge_percent <= 80) { 27 | return RESOURCE_ID_BATTERY_75; 28 | } else { 29 | return RESOURCE_ID_BATTERY_100; 30 | } 31 | } 32 | 33 | static void battery_handler(BatteryChargeState charge_state) { 34 | if (get_prefs()->battery_as_number) { 35 | 36 | if (charge_state.is_charging) { 37 | text_layer_set_text(s_component->text_layer, "+%"); 38 | } else { 39 | static char battery_text[8]; 40 | snprintf(battery_text, sizeof(battery_text), "%d%%", charge_state.charge_percent); 41 | text_layer_set_text(s_component->text_layer, battery_text); 42 | } 43 | layer_mark_dirty(text_layer_get_layer(s_component->text_layer)); 44 | 45 | } else { 46 | 47 | if (s_component->icon_bitmap != NULL) { 48 | gbitmap_destroy(s_component->icon_bitmap); 49 | } 50 | s_component->icon_bitmap = gbitmap_create_with_resource(battery_icon_id(charge_state)); 51 | bitmap_layer_set_bitmap(s_component->icon_layer, s_component->icon_bitmap); 52 | 53 | // bitmap_layer_set_bitmap is supposed to trigger this automatically. 54 | // https://forums.getpebble.com/discussion/comment/129517/#Comment_129517 55 | layer_mark_dirty(bitmap_layer_get_layer(s_component->icon_layer)); 56 | 57 | } 58 | } 59 | 60 | uint8_t battery_component_width() { 61 | return get_prefs()->battery_as_number ? BATTERY_TEXT_WIDTH : BATTERY_ICON_WIDTH; 62 | } 63 | 64 | uint8_t battery_component_height() { 65 | return get_prefs()->battery_as_number ? get_font(BATTERY_FONT).height + 2 * get_font(BATTERY_FONT).padding_bottom : BATTERY_ICON_HEIGHT; 66 | } 67 | 68 | uint8_t battery_component_vertical_padding() { 69 | return get_prefs()->battery_as_number ? get_font(BATTERY_FONT).padding_bottom : BATTERY_ICON_PADDING; 70 | } 71 | 72 | BatteryComponent* battery_component_create(Layer *parent, int16_t x, int16_t y, bool align_right) { 73 | battery_state_service_subscribe(battery_handler); 74 | 75 | BatteryComponent *c = malloc(sizeof(BatteryComponent)); 76 | c->icon_layer = NULL; 77 | c->icon_bitmap = NULL; 78 | c->text_layer = NULL; 79 | 80 | if (get_prefs()->battery_as_number) { 81 | 82 | FontChoice font = get_font(BATTERY_FONT); 83 | c->text_layer = add_text_layer( 84 | parent, 85 | GRect(x, y - font.padding_top + font.padding_bottom, BATTERY_TEXT_WIDTH, font.height + font.padding_top + font.padding_bottom), 86 | fonts_get_system_font(font.key), 87 | element_fg(parent), 88 | align_right ? GTextAlignmentRight : GTextAlignmentLeft 89 | ); 90 | 91 | } else { 92 | 93 | c->icon_layer = bitmap_layer_create(GRect(x, y + BATTERY_ICON_TOP_FUDGE, BATTERY_ICON_WIDTH, BATTERY_ICON_HEIGHT)); 94 | bitmap_layer_set_compositing_mode(c->icon_layer, element_comp_op(parent)); 95 | layer_add_child(parent, bitmap_layer_get_layer(c->icon_layer)); 96 | 97 | } 98 | 99 | // XXX 100 | s_component = c; 101 | battery_handler(battery_state_service_peek()); 102 | 103 | return c; 104 | } 105 | 106 | void battery_component_destroy(BatteryComponent *c) { 107 | if (c->icon_bitmap != NULL) { 108 | gbitmap_destroy(c->icon_bitmap); 109 | } 110 | if (c->icon_layer != NULL) { 111 | bitmap_layer_destroy(c->icon_layer); 112 | } 113 | if (c->text_layer != NULL) { 114 | text_layer_destroy(c->text_layer); 115 | } 116 | free(c); 117 | } 118 | -------------------------------------------------------------------------------- /src/time_element.c: -------------------------------------------------------------------------------- 1 | #include "fonts.h" 2 | #include "layout.h" 3 | #include "preferences.h" 4 | #include "time_element.h" 5 | 6 | #define TESTING_TIME_DISPLAY "13:37" 7 | 8 | static BatteryComponent *create_battery_component(Layer *parent, uint8_t battery_loc) { 9 | GRect bounds = element_get_bounds(parent); 10 | int x = -1; 11 | int y = -1; 12 | bool align_right; 13 | if (battery_loc == BATTERY_LOC_TIME_TOP_LEFT) { 14 | x = battery_component_vertical_padding(); 15 | y = 0; 16 | align_right = false; 17 | } else if (battery_loc == BATTERY_LOC_TIME_TOP_RIGHT) { 18 | x = bounds.size.w - battery_component_width() - battery_component_vertical_padding(); 19 | y = 0; 20 | align_right = true; 21 | } else if (battery_loc == BATTERY_LOC_TIME_BOTTOM_LEFT) { 22 | x = battery_component_vertical_padding(); 23 | y = bounds.size.h - battery_component_height(); 24 | align_right = false; 25 | } else if (battery_loc == BATTERY_LOC_TIME_BOTTOM_RIGHT) { 26 | x = bounds.size.w - battery_component_width() - battery_component_vertical_padding(); 27 | y = bounds.size.h - battery_component_height(); 28 | align_right = true; 29 | } 30 | if (bounds.size.h <= battery_component_height()) { 31 | y = (bounds.size.h - battery_component_height()) / 2 - 1; 32 | } 33 | if (x != -1) { 34 | return battery_component_create(parent, x, y, align_right); 35 | } else { 36 | return NULL; 37 | } 38 | } 39 | 40 | static RecencyComponent *create_recency_component(Layer *parent, uint8_t recency_loc) { 41 | GRect bounds = element_get_bounds(parent); 42 | int16_t y = -1; 43 | bool align_right; 44 | if (recency_loc == RECENCY_LOC_TIME_TOP_LEFT) { 45 | y = 0; 46 | align_right = false; 47 | } else if (recency_loc == RECENCY_LOC_TIME_TOP_RIGHT) { 48 | y = 0; 49 | align_right = true; 50 | } else if (recency_loc == RECENCY_LOC_TIME_BOTTOM_LEFT) { 51 | y = bounds.size.h - recency_component_height(); 52 | align_right = false; 53 | } else if (recency_loc == RECENCY_LOC_TIME_BOTTOM_RIGHT) { 54 | y = bounds.size.h - recency_component_height(); 55 | align_right = true; 56 | } 57 | 58 | if (y != -1) { 59 | return recency_component_create(parent, y, align_right, NULL, NULL); 60 | } else { 61 | return NULL; 62 | } 63 | } 64 | 65 | static uint8_t choose_font_for_height(uint8_t height) { 66 | uint8_t choices[] = {FONT_42_BOLD, FONT_34_NUMBERS, FONT_28_BOLD, FONT_24_BOLD, FONT_18_BOLD}; 67 | for(uint8_t i = 0; i < ARRAY_LENGTH(choices); i++) { 68 | if (get_font(choices[i]).height < height) { 69 | return choices[i]; 70 | } 71 | } 72 | return choices[ARRAY_LENGTH(choices) - 1]; 73 | } 74 | 75 | TimeElement* time_element_create(Layer* parent) { 76 | GRect bounds = element_get_bounds(parent); 77 | Preferences *prefs = get_prefs(); 78 | 79 | const int time_margin = 2; 80 | FontChoice font = get_font(choose_font_for_height(bounds.size.h)); 81 | 82 | TimeElement* out = malloc(sizeof(TimeElement)); 83 | 84 | TextLayer* time_text = add_text_layer( 85 | parent, 86 | GRect(time_margin, (bounds.size.h - font.height) / 2 - font.padding_top, bounds.size.w - 2 * time_margin, font.height + font.padding_top + font.padding_bottom), 87 | fonts_get_system_font(font.key), 88 | element_fg(parent), 89 | prefs->time_align == ALIGN_LEFT ? GTextAlignmentLeft : (prefs->time_align == ALIGN_CENTER ? GTextAlignmentCenter : GTextAlignmentRight) 90 | ); 91 | 92 | out->time_text = time_text; 93 | out->battery = create_battery_component(parent, prefs->battery_loc); 94 | out->recency = create_recency_component(parent, prefs->recency_loc); 95 | return out; 96 | } 97 | 98 | void time_element_destroy(TimeElement* el) { 99 | text_layer_destroy(el->time_text); 100 | if (el->battery != NULL) { 101 | battery_component_destroy(el->battery); 102 | } 103 | if (el->recency != NULL) { 104 | recency_component_destroy(el->recency); 105 | } 106 | free(el); 107 | } 108 | 109 | void time_element_update(TimeElement *el, DataMessage *data) { 110 | time_element_tick(el); 111 | } 112 | 113 | void time_element_tick(TimeElement *el) { 114 | static char buffer[16]; 115 | 116 | #ifdef IS_TEST_BUILD 117 | strcpy(buffer, TESTING_TIME_DISPLAY); 118 | #else 119 | clock_copy_time_string(buffer, 16); 120 | 121 | if (!clock_is_24h_style()) { 122 | // remove " AM" suffix 123 | if(buffer[4] == ' ') { 124 | buffer[4] = 0; 125 | } else { 126 | buffer[5] = 0; 127 | }; 128 | } 129 | #endif 130 | 131 | text_layer_set_text(el->time_text, buffer); 132 | 133 | if (el->recency != NULL) { 134 | recency_component_tick(el->recency); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/preferences.c: -------------------------------------------------------------------------------- 1 | #include "preferences.h" 2 | 3 | static Preferences *s_prefs = NULL; 4 | 5 | static void save_prefs() { 6 | persist_write_int(PERSIST_KEY_VERSION, PREFERENCES_SCHEMA_VERSION); 7 | persist_write_data(PERSIST_KEY_PREFERENCES_OBJECT, s_prefs, sizeof(Preferences)); 8 | } 9 | 10 | static void set_empty_prefs() { 11 | s_prefs->num_elements = 0; 12 | } 13 | 14 | void init_prefs() { 15 | #ifndef PBL_APLITE 16 | if (sizeof(Preferences) > PERSIST_DATA_MAX_LENGTH) { 17 | APP_LOG(APP_LOG_LEVEL_ERROR, "Preferences data too big!"); 18 | } 19 | #endif 20 | s_prefs = malloc(sizeof(Preferences)); 21 | 22 | if ( 23 | persist_exists(PERSIST_KEY_VERSION) && \ 24 | persist_exists(PERSIST_KEY_PREFERENCES_OBJECT) && \ 25 | persist_read_int(PERSIST_KEY_VERSION) == PREFERENCES_SCHEMA_VERSION 26 | ) { 27 | persist_read_data(PERSIST_KEY_PREFERENCES_OBJECT, s_prefs, sizeof(Preferences)); 28 | } else { 29 | set_empty_prefs(); 30 | } 31 | } 32 | 33 | void deinit_prefs() { 34 | free(s_prefs); 35 | } 36 | 37 | Preferences* get_prefs() { 38 | return s_prefs; 39 | } 40 | 41 | static ElementConfig decode_layout_element(uint8_t *encoded, uint8_t offset) { 42 | ElementConfig decoded; 43 | decoded.el = encoded[offset + ELEMENT_TYPE]; 44 | decoded.w = encoded[offset + ELEMENT_WIDTH]; 45 | decoded.h = encoded[offset + ELEMENT_HEIGHT]; 46 | decoded.black = encoded[offset + ELEMENT_BLACK]; 47 | decoded.bottom = encoded[offset + ELEMENT_BOTTOM]; 48 | decoded.right = encoded[offset + ELEMENT_RIGHT]; 49 | return decoded; 50 | } 51 | 52 | static void decode_layout_elements(Preferences *prefs, uint8_t num_elements, uint8_t *encoded) { 53 | for(uint8_t i = 0; i < num_elements; i++) { 54 | ElementConfig el = decode_layout_element(encoded, NUM_ELEMENT_PROPERTIES * i); 55 | memcpy(&prefs->elements[i], &el, sizeof(ElementConfig)); 56 | } 57 | } 58 | 59 | static void decode_colors(Preferences *prefs, uint8_t *values) { 60 | for (uint8_t i = 0; i < NUM_COLOR_KEYS; i++) { 61 | s_prefs->colors[i] = (GColor){.argb=(values[i])}; 62 | } 63 | } 64 | 65 | static int32_t get_int32(DictionaryIterator *data, uint8_t key) { 66 | Tuple *t = dict_find(data, key); 67 | if (t == NULL) { 68 | APP_LOG(APP_LOG_LEVEL_ERROR, "No pref %d", (int)key); 69 | return 0; 70 | } else { 71 | return t->value->int32; 72 | } 73 | } 74 | 75 | void set_prefs(DictionaryIterator *data) { 76 | s_prefs->mmol = get_int32(data, MESSAGE_KEY_mmol); 77 | s_prefs->top_of_graph = get_int32(data, MESSAGE_KEY_topOfGraph); 78 | s_prefs->top_of_range = get_int32(data, MESSAGE_KEY_topOfRange); 79 | s_prefs->bottom_of_range = get_int32(data, MESSAGE_KEY_bottomOfRange); 80 | s_prefs->bottom_of_graph = get_int32(data, MESSAGE_KEY_bottomOfGraph); 81 | s_prefs->h_gridlines = get_int32(data, MESSAGE_KEY_hGridlines); 82 | s_prefs->battery_as_number = get_int32(data, MESSAGE_KEY_batteryAsNumber); 83 | s_prefs->basal_graph = get_int32(data, MESSAGE_KEY_basalGraph); 84 | s_prefs->basal_height = get_int32(data, MESSAGE_KEY_basalHeight); 85 | s_prefs->update_every_minute = get_int32(data, MESSAGE_KEY_updateEveryMinute); 86 | s_prefs->time_align = get_int32(data, MESSAGE_KEY_timeAlign); 87 | s_prefs->battery_loc = get_int32(data, MESSAGE_KEY_batteryLoc); 88 | s_prefs->conn_status_loc = get_int32(data, MESSAGE_KEY_connStatusLoc); 89 | s_prefs->recency_loc = get_int32(data, MESSAGE_KEY_recencyLoc); 90 | s_prefs->recency_style = get_int32(data, MESSAGE_KEY_recencyStyle); 91 | s_prefs->point_shape = get_int32(data, MESSAGE_KEY_pointShape); 92 | s_prefs->point_rect_height = get_int32(data, MESSAGE_KEY_pointRectHeight); 93 | s_prefs->point_width = get_int32(data, MESSAGE_KEY_pointWidth); 94 | s_prefs->point_margin = get_int32(data, MESSAGE_KEY_pointMargin); 95 | s_prefs->point_right_margin = get_int32(data, MESSAGE_KEY_pointRightMargin); 96 | s_prefs->plot_line = get_int32(data, MESSAGE_KEY_plotLine); 97 | s_prefs->plot_line_width = get_int32(data, MESSAGE_KEY_plotLineWidth); 98 | s_prefs->plot_line_is_custom_color = get_int32(data, MESSAGE_KEY_plotLineIsCustomColor); 99 | s_prefs->status_min_recency_to_show_minutes = get_int32(data, MESSAGE_KEY_statusMinRecencyToShowMinutes); 100 | s_prefs->status_max_age_minutes = get_int32(data, MESSAGE_KEY_statusMaxAgeMinutes); 101 | s_prefs->status_recency_format = get_int32(data, MESSAGE_KEY_statusRecencyFormat); 102 | 103 | s_prefs->num_elements = get_int32(data, MESSAGE_KEY_numElements); 104 | decode_layout_elements(s_prefs, s_prefs->num_elements, dict_find(data, MESSAGE_KEY_elements)->value->data); 105 | 106 | decode_colors(s_prefs, dict_find(data, MESSAGE_KEY_colors)->value->data); 107 | 108 | save_prefs(); 109 | } 110 | -------------------------------------------------------------------------------- /test/js/test_cache.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | /* globals describe, it, beforeEach */ 3 | "use strict"; 4 | 5 | var expect = require('expect.js'), 6 | timekeeper = require('timekeeper'); 7 | 8 | var cache = require('../../src/js/cache.js'); 9 | 10 | describe('Cache', function() { 11 | beforeEach(function() { 12 | global.localStorage = require('./make_mock_local_storage.js')(); 13 | }); 14 | 15 | describe('new', function() { 16 | it('should use a distinct localStorage key', function() { 17 | var first = new cache.WithMaxSize('first', 10); 18 | first.update([9, 8, 7]); 19 | var second = new cache.WithMaxSize('second', 10); 20 | second.update([5, 4, 3]); 21 | expect(first.entries).to.eql([9, 8, 7]); 22 | expect(second.entries).to.eql([5, 4, 3]); 23 | }); 24 | }); 25 | 26 | describe('update', function() { 27 | it('should prepend', function() { 28 | var c = new cache.WithMaxSize('foo', 10); 29 | c.update([1, 2]); 30 | c.update([3, 4]); 31 | expect(c.entries).to.eql([3, 4, 1, 2]); 32 | }); 33 | 34 | it('should truncate after maxSize', function() { 35 | var c = new cache.WithMaxSize('foo', 6); 36 | c.update([1, 2, 3, 4]); 37 | c.update([5, 6, 7, 8]); 38 | expect(c.entries).to.eql([5, 6, 7, 8, 1, 2]); 39 | }); 40 | 41 | it('should truncate after maxSecondsOld', function() { 42 | var now = new Date(); 43 | timekeeper.freeze(now); 44 | var c = new cache.WithMaxAge('bar', 10 * 60); 45 | c.update([ 46 | {'sgv': 1, 'date': now - 3 * 60 * 1000}, 47 | {'sgv': 2, 'date': now - 5 * 60 * 1000}, 48 | {'sgv': 3, 'date': now - 10 * 60 * 1000}, 49 | {'sgv': 4, 'date': now - 10 * 60 * 1000 - 1}, 50 | ]); 51 | expect(c.entries).to.eql([ 52 | {'sgv': 1, 'date': now - 3 * 60 * 1000}, 53 | {'sgv': 2, 'date': now - 5 * 60 * 1000}, 54 | {'sgv': 3, 'date': now - 10 * 60 * 1000}, 55 | ]); 56 | }); 57 | }); 58 | 59 | describe('revive', function() { 60 | it('should revive from localStorage', function() { 61 | var first = new cache.WithMaxSize('sameKey', 5); 62 | first.update([99, 88, 77]); 63 | var second = new cache.WithMaxSize('sameKey', 5); 64 | expect(second.entries).to.eql([99, 88, 77]); 65 | }); 66 | }); 67 | 68 | describe('clear', function() { 69 | it('should empty the cache, which remains empty after reviving', function() { 70 | var first = new cache.WithMaxSize('sameKey', 5); 71 | first.update([123, 456]); 72 | expect(first.entries).to.eql([123, 456]); 73 | first.clear(); 74 | expect(first.entries).to.eql([]); 75 | var second = new cache.WithMaxSize('sameKey', 5); 76 | expect(second.entries).to.eql([]); 77 | }); 78 | }); 79 | 80 | describe('setMaxSize', function() { 81 | it('should change the cache capacity', function() { 82 | var c = new cache.WithMaxSize('foo', 4); 83 | c.update([1, 2, 3, 4, 5, 6]); 84 | expect(c.entries).to.eql([1, 2, 3, 4]); 85 | c.update([9, 8]); 86 | expect(c.entries).to.eql([9, 8, 1, 2]); 87 | c.setMaxSize(6); 88 | c.update([11, 12, 13]); 89 | expect(c.entries).to.eql([11, 12, 13, 9, 8, 1]); 90 | c.setMaxSize(3); 91 | c.update([-5]); 92 | expect(c.entries).to.eql([-5, 11, 12]); 93 | }); 94 | }); 95 | 96 | describe('setMaxSecondsOld', function() { 97 | it('should change the cache threshold', function() { 98 | var now = new Date(); 99 | timekeeper.freeze(now); 100 | var c = new cache.WithMaxAge('bar', 10 * 60); 101 | c.update([ 102 | {'sgv': 1, 'date': now - 3 * 60 * 1000}, 103 | {'sgv': 2, 'date': now - 5 * 60 * 1000}, 104 | {'sgv': 3, 'date': now - 10 * 60 * 1000}, 105 | ]); 106 | expect(c.entries).to.eql([ 107 | {'sgv': 1, 'date': now - 3 * 60 * 1000}, 108 | {'sgv': 2, 'date': now - 5 * 60 * 1000}, 109 | {'sgv': 3, 'date': now - 10 * 60 * 1000}, 110 | ]); 111 | c.setMaxSecondsOld(6 * 60); 112 | c.update([ 113 | {'sgv': 0, 'date': now - 2 * 60 * 1000}, 114 | ]); 115 | expect(c.entries).to.eql([ 116 | {'sgv': 0, 'date': now - 2 * 60 * 1000}, 117 | {'sgv': 1, 'date': now - 3 * 60 * 1000}, 118 | {'sgv': 2, 'date': now - 5 * 60 * 1000}, 119 | ]); 120 | c.setMaxSecondsOld(2.5 * 60); 121 | c.update([ 122 | {'sgv': -1, 'date': now - 1 * 60 * 1000}, 123 | ]); 124 | expect(c.entries).to.eql([ 125 | {'sgv': -1, 'date': now - 1 * 60 * 1000}, 126 | {'sgv': 0, 'date': now - 2 * 60 * 1000}, 127 | ]); 128 | }); 129 | }); 130 | 131 | }); 132 | -------------------------------------------------------------------------------- /src/main.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "app_messages.h" 4 | #include "bg_row_element.h" 5 | #include "comm.h" 6 | #include "layout.h" 7 | #include "graph_element.h" 8 | #include "preferences.h" 9 | #include "staleness.h" 10 | #include "status_bar_element.h" 11 | #include "sidebar_element.h" 12 | #include "time_element.h" 13 | 14 | static Window *s_window; 15 | 16 | static TimeElement *s_time_element = NULL; 17 | static GraphElement *s_graph_element = NULL; 18 | static SidebarElement *s_sidebar_element = NULL; 19 | static StatusBarElement *s_status_bar_element = NULL; 20 | static BGRowElement *s_bg_row_element = NULL; 21 | 22 | static void minute_handler(struct tm *tick_time, TimeUnits units_changed) { 23 | if (s_time_element != NULL) { 24 | time_element_tick(s_time_element); 25 | } 26 | if (s_graph_element != NULL) { 27 | graph_element_tick(s_graph_element); 28 | } 29 | if (s_sidebar_element != NULL) { 30 | sidebar_element_tick(s_sidebar_element); 31 | } 32 | if (s_status_bar_element != NULL) { 33 | status_bar_element_tick(s_status_bar_element); 34 | } 35 | if (s_bg_row_element != NULL) { 36 | bg_row_element_tick(s_bg_row_element); 37 | } 38 | } 39 | 40 | static void window_load(Window *window) { 41 | LayoutLayers layout = init_layout(window); 42 | 43 | if (layout.time_area != NULL) { 44 | // ensure the time is drawn before anything else 45 | s_time_element = time_element_create(layout.time_area); 46 | time_element_tick(s_time_element); 47 | } 48 | 49 | if (layout.graph != NULL) { 50 | s_graph_element = graph_element_create(layout.graph); 51 | } 52 | if (layout.sidebar != NULL) { 53 | s_sidebar_element = sidebar_element_create(layout.sidebar); 54 | } 55 | if (layout.status_bar != NULL) { 56 | s_status_bar_element = status_bar_element_create(layout.status_bar); 57 | } 58 | if (layout.bg_row != NULL) { 59 | s_bg_row_element = bg_row_element_create(layout.bg_row); 60 | } 61 | 62 | tick_timer_service_subscribe(MINUTE_UNIT, minute_handler); 63 | } 64 | 65 | static void window_unload(Window *window) { 66 | if (s_time_element != NULL) { 67 | time_element_destroy(s_time_element); 68 | s_time_element = NULL; 69 | } 70 | if (s_graph_element != NULL) { 71 | graph_element_destroy(s_graph_element); 72 | s_graph_element = NULL; 73 | } 74 | if (s_sidebar_element != NULL) { 75 | sidebar_element_destroy(s_sidebar_element); 76 | s_sidebar_element = NULL; 77 | } 78 | if (s_status_bar_element != NULL) { 79 | status_bar_element_destroy(s_status_bar_element); 80 | s_status_bar_element = NULL; 81 | } 82 | if (s_bg_row_element != NULL) { 83 | bg_row_element_destroy(s_bg_row_element); 84 | s_bg_row_element = NULL; 85 | } 86 | 87 | deinit_layout(); 88 | } 89 | 90 | static Window *create_main_window() { 91 | Window *window = window_create(); 92 | window_set_window_handlers(window, (WindowHandlers) { 93 | .load = window_load, 94 | .unload = window_unload, 95 | }); 96 | window_stack_push(window, false); 97 | return window; 98 | } 99 | 100 | static void data_callback(DataMessage *data) { 101 | staleness_on_data_received(data->recency); 102 | 103 | if (s_time_element != NULL) { 104 | time_element_update(s_time_element, data); 105 | } 106 | if (s_graph_element != NULL) { 107 | graph_element_update(s_graph_element, data); 108 | } 109 | if (s_sidebar_element != NULL) { 110 | sidebar_element_update(s_sidebar_element, data); 111 | } 112 | if (s_status_bar_element != NULL) { 113 | status_bar_element_update(s_status_bar_element, data); 114 | } 115 | if (s_bg_row_element != NULL) { 116 | bg_row_element_update(s_bg_row_element, data); 117 | } 118 | } 119 | 120 | static void prefs_callback(DictionaryIterator *received) { 121 | set_prefs(received); 122 | // recreate the window in case layout preferences have changed 123 | window_stack_remove(s_window, false); 124 | window_destroy(s_window); 125 | s_window = create_main_window(); 126 | } 127 | 128 | static void request_state_callback(RequestState state, AppMessageResult reason) { 129 | staleness_on_request_state_changed(state); 130 | 131 | // TODO: implement this method on the other elements. 132 | // For now it's implicit that only the ConnectionStatusComponent shows the 133 | // request state, and only the GraphElement contains that component. 134 | if (s_graph_element != NULL) { 135 | graph_element_show_request_state(s_graph_element, state, reason); 136 | } 137 | } 138 | 139 | static void init(void) { 140 | init_prefs(); 141 | init_staleness(); 142 | s_window = create_main_window(); 143 | init_comm(data_callback, prefs_callback, request_state_callback); 144 | } 145 | 146 | static void deinit(void) { 147 | deinit_comm(); 148 | window_destroy(s_window); 149 | deinit_prefs(); 150 | } 151 | 152 | int main(void) { 153 | init(); 154 | app_event_loop(); 155 | deinit(); 156 | } 157 | -------------------------------------------------------------------------------- /wscript: -------------------------------------------------------------------------------- 1 | import errno 2 | import json 3 | import os 4 | 5 | from waflib.Task import Task 6 | 7 | from make_inline_config import make_inline_config 8 | 9 | def add_environment_specific_constants(out, build_env, debug): 10 | # This file makes the data URI for the config page viewable in the emulator. 11 | out['CONFIG_PROXY_URL'] = 'file://{}/proxy_config.html'.format(os.path.abspath(os.path.curdir)) 12 | 13 | if build_env == 'test': 14 | # Don't clobber config in localStorage with test config 15 | out['LOCAL_STORAGE_KEY_CONFIG'] = 'test_config' 16 | elif build_env == 'development': 17 | # Normally the config page is stored in a data URI viewed "offline" on 18 | # the phone. In development, open the source HTML page locally instead. 19 | out['DEV_CONFIG_URL'] = 'file://{}/config/index.html'.format(os.path.abspath(os.path.curdir)) 20 | if debug: 21 | out['DEBUG'] = True 22 | 23 | class generate_constants_json(Task): 24 | vars = ['BUILD_ENV', 'DEBUG'] 25 | def run(self): 26 | constants = json.loads(self.inputs[0].read()) 27 | add_environment_specific_constants(constants, self.env.BUILD_ENV, self.env.DEBUG) 28 | self.outputs[0].write(json.dumps(constants)) 29 | 30 | class generate_js_includes_for_config_page(Task): 31 | vars = ['BUILD_ENV', 'DEBUG'] 32 | def run(self): 33 | constants = json.loads(self.inputs[0].read()) 34 | add_environment_specific_constants(constants, self.env.BUILD_ENV, self.env.DEBUG) 35 | includes = "window.CONSTANTS = {};".format(json.dumps(constants)) 36 | for js_file in self.inputs[1:]: 37 | includes += '\n(function() { /* %s */\n%s\n})();' % (js_file.relpath(), js_file.read()) 38 | self.outputs[0].write(includes) 39 | 40 | class convert_config_page_to_data_uri(Task): 41 | def run(self): 42 | html_file = [i for i in self.inputs if i.name == 'index.html'][0] 43 | self.outputs[0].write(make_inline_config(self, html_file)) 44 | 45 | top = '.' 46 | out = 'build' 47 | 48 | def options(ctx): 49 | ctx.load('pebble_sdk') 50 | 51 | def configure(ctx): 52 | ctx.load('pebble_sdk') 53 | 54 | def distclean(ctx): 55 | for build_dir in (out, 'src/js/generated', 'config/js/generated'): 56 | found = ctx.path.find_dir(build_dir) 57 | if found: 58 | cmd = 'rm -r {}'.format(found.abspath()) 59 | print cmd 60 | ctx.exec_command(cmd) 61 | 62 | def build(ctx): 63 | ctx.load('pebble_sdk') 64 | 65 | binaries = [] 66 | 67 | for p in ctx.env.TARGET_PLATFORMS: 68 | ctx.set_env(ctx.all_envs[p]) 69 | ctx.set_group(ctx.env.PLATFORM_NAME) 70 | if os.environ.get('BUILD_ENV') == 'test': 71 | # When running screenshot tests, the watchface needs to know it's 72 | # under test so that fake data can be shown (e.g. current time). 73 | ctx.env.append_value('DEFINES', 'IS_TEST_BUILD') 74 | if os.environ.get('DEBUG'): 75 | ctx.env.append_value('DEFINES', 'DEBUG') 76 | 77 | app_elf='{}/pebble-app.elf'.format(ctx.env.BUILD_DIR) 78 | ctx.pbl_program(source=ctx.path.ant_glob('src/**/*.c'), target=app_elf) 79 | binaries.append({'platform': p, 'app_elf': app_elf}) 80 | 81 | ctx.set_group('bundle') 82 | 83 | ctx.env.BUILD_ENV = os.environ.get('BUILD_ENV', 'production') 84 | ctx.env.DEBUG = os.environ.get('DEBUG') 85 | 86 | config_js_includes = ctx.srcnode.make_node('config/js/generated/includes.js') 87 | config_js_includes.parent.mkdir() 88 | gen_js_includes = generate_js_includes_for_config_page(env=ctx.env) 89 | gen_js_includes.set_inputs([ 90 | ctx.path.find_resource('src/js/constants.json'), 91 | ctx.path.find_resource('config/js/vendor.min.js'), 92 | ctx.path.find_resource('src/js/points.js'), 93 | ctx.path.find_resource('src/js/status_formatters.js'), 94 | ]) 95 | gen_js_includes.set_outputs(config_js_includes) 96 | ctx.add_to_group(gen_js_includes) 97 | 98 | # This must be in the src/ directory to be available to `require` in JS 99 | constants_json = ctx.srcnode.make_node('src/js/generated/constants.json') 100 | constants_json.parent.mkdir() 101 | gen_constants = generate_constants_json(env=ctx.env) 102 | gen_constants.set_inputs(ctx.path.find_resource('src/js/constants.json')) 103 | gen_constants.set_outputs(constants_json) 104 | ctx.add_to_group(gen_constants) 105 | 106 | config_page = ctx.srcnode.make_node('src/js/generated/config_page.json') 107 | config_page.parent.mkdir() 108 | convert_config_page = convert_config_page_to_data_uri(env=ctx.env) 109 | convert_config_page.set_inputs(ctx.path.ant_glob('config/**/*')) 110 | convert_config_page.set_outputs(config_page) 111 | ctx.add_to_group(convert_config_page) 112 | 113 | ctx.pbl_bundle( 114 | binaries=binaries, 115 | js=ctx.path.ant_glob(['src/js/**/*.js', 'src/js/**/*.json']) + [constants_json, config_page], 116 | js_entry_file='src/js/app.js' 117 | ) 118 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "urchin-cgm", 3 | "author": "Mark Wilson", 4 | "version": "0.1.0", 5 | "keywords": ["pebble-app"], 6 | "private": true, 7 | "devDependencies": { 8 | "clean-css": "~3.4.20", 9 | "html-minifier": "~3.1.0", 10 | "uglify-js": "~2.7.3" 11 | }, 12 | "pebble": { 13 | "displayName": "Urchin CGM", 14 | "uuid": "ea361603-0373-4865-9824-8f52c65c6e07", 15 | "projectType": "native", 16 | "sdkVersion": "3", 17 | "enableMultiJS": true, 18 | "targetPlatforms": [ 19 | "aplite", 20 | "basalt" 21 | ], 22 | "watchapp": { 23 | "watchface": true 24 | }, 25 | "capabilities": [ 26 | "configurable" 27 | ], 28 | "messageKeys": { 29 | "msgType": 0, 30 | 31 | "recency": 1, 32 | "__DEPRECATED__sgvCount": 2, 33 | "sgvs": 3, 34 | "lastSgv": 4, 35 | "trend": 5, 36 | "delta": 6, 37 | "statusText": 7, 38 | "graphExtra": 8, 39 | "statusRecency": 9, 40 | "prediction1": 10, 41 | "prediction2": 11, 42 | "prediction3": 12, 43 | "predictionRecency": 13, 44 | 45 | "mmol": 1, 46 | "topOfGraph": 2, 47 | "topOfRange": 3, 48 | "bottomOfRange": 4, 49 | "bottomOfGraph": 5, 50 | "hGridlines": 6, 51 | "batteryAsNumber": 7, 52 | "basalGraph": 8, 53 | "basalHeight": 9, 54 | "updateEveryMinute": 10, 55 | "timeAlign": 11, 56 | "batteryLoc": 12, 57 | "connStatusLoc": 13, 58 | "recencyLoc": 14, 59 | "recencyStyle": 15, 60 | "pointShape": 16, 61 | "pointRectHeight": 17, 62 | "pointWidth": 18, 63 | "pointMargin": 19, 64 | "pointRightMargin": 20, 65 | "plotLine": 21, 66 | "plotLineWidth": 22, 67 | "plotLineIsCustomColor": 23, 68 | "numElements": 24, 69 | "elements": 25, 70 | "colors": 26, 71 | "statusMinRecencyToShowMinutes": 27, 72 | "statusMaxAgeMinutes": 28, 73 | "statusRecencyFormat": 29 74 | }, 75 | "resources": { 76 | "media": [ 77 | { 78 | "type": "bitmap", 79 | "memoryFormat": "1Bit", 80 | "name": "ARROW_DOUBLE_UP", 81 | "file": "images/arrow_double_up.png" 82 | }, 83 | { 84 | "type": "bitmap", 85 | "memoryFormat": "1Bit", 86 | "name": "ARROW_SINGLE_UP", 87 | "file": "images/arrow_single_up.png" 88 | }, 89 | { 90 | "type": "bitmap", 91 | "memoryFormat": "1Bit", 92 | "name": "ARROW_FORTY_FIVE_UP", 93 | "file": "images/arrow_forty_five_up.png" 94 | }, 95 | { 96 | "type": "bitmap", 97 | "memoryFormat": "1Bit", 98 | "name": "ARROW_FLAT", 99 | "file": "images/arrow_flat.png" 100 | }, 101 | { 102 | "type": "bitmap", 103 | "memoryFormat": "1Bit", 104 | "name": "ARROW_FORTY_FIVE_DOWN", 105 | "file": "images/arrow_forty_five_down.png" 106 | }, 107 | { 108 | "type": "bitmap", 109 | "memoryFormat": "1Bit", 110 | "name": "ARROW_SINGLE_DOWN", 111 | "file": "images/arrow_single_down.png" 112 | }, 113 | { 114 | "type": "bitmap", 115 | "memoryFormat": "1Bit", 116 | "name": "ARROW_DOUBLE_DOWN", 117 | "file": "images/arrow_double_down.png" 118 | }, 119 | { 120 | "type": "bitmap", 121 | "memoryFormat": "1Bit", 122 | "name": "CONN_ISSUE_BLUETOOTH", 123 | "file": "images/conn_issue_bluetooth.png" 124 | }, 125 | { 126 | "type": "bitmap", 127 | "memoryFormat": "1Bit", 128 | "name": "CONN_ISSUE_NETWORK", 129 | "file": "images/conn_issue_network.png" 130 | }, 131 | { 132 | "type": "bitmap", 133 | "memoryFormat": "1Bit", 134 | "name": "CONN_ISSUE_RIG", 135 | "file": "images/conn_issue_rig.png" 136 | }, 137 | { 138 | "type": "bitmap", 139 | "memoryFormat": "1Bit", 140 | "name": "CONN_REFRESHING", 141 | "file": "images/conn_refreshing.png" 142 | }, 143 | { 144 | "type": "bitmap", 145 | "memoryFormat": "1Bit", 146 | "name": "BATTERY_10", 147 | "file": "images/battery_10.png" 148 | }, 149 | { 150 | "type": "bitmap", 151 | "memoryFormat": "1Bit", 152 | "name": "BATTERY_25", 153 | "file": "images/battery_25.png" 154 | }, 155 | { 156 | "type": "bitmap", 157 | "memoryFormat": "1Bit", 158 | "name": "BATTERY_50", 159 | "file": "images/battery_50.png" 160 | }, 161 | { 162 | "type": "bitmap", 163 | "memoryFormat": "1Bit", 164 | "name": "BATTERY_75", 165 | "file": "images/battery_75.png" 166 | }, 167 | { 168 | "type": "bitmap", 169 | "memoryFormat": "1Bit", 170 | "name": "BATTERY_100", 171 | "file": "images/battery_100.png" 172 | }, 173 | { 174 | "type": "bitmap", 175 | "memoryFormat": "1Bit", 176 | "name": "BATTERY_CHARGING", 177 | "file": "images/battery_charging.png" 178 | } 179 | ] 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/recency_component.c: -------------------------------------------------------------------------------- 1 | #include "app_messages.h" 2 | #include "fonts.h" 3 | #include "format.h" 4 | #include "layout.h" 5 | #include "preferences.h" 6 | #include "recency_component.h" 7 | 8 | 9 | RecencyStyle get_style() { 10 | // NOTE: circles can have odd diameters only 11 | switch(get_prefs()->recency_style) { 12 | case RECENCY_STYLE_SMALL_NO_CIRCLE: 13 | return (RecencyStyle) {.font = FONT_18_BOLD, .diameter = 11, .inset = 0}; 14 | case RECENCY_STYLE_MEDIUM_PIE: 15 | return (RecencyStyle) {.font = FONT_24_BOLD, .diameter = 23, .inset = 23}; 16 | case RECENCY_STYLE_MEDIUM_RING: 17 | return (RecencyStyle) {.font = FONT_24_BOLD, .diameter = 23, .inset = 3}; 18 | case RECENCY_STYLE_MEDIUM_NO_CIRCLE: 19 | return (RecencyStyle) {.font = FONT_24_BOLD, .diameter = 15, .inset = 0}; 20 | case RECENCY_STYLE_LARGE_PIE: 21 | return (RecencyStyle) {.font = FONT_28_BOLD, .diameter = 29, .inset = 29}; 22 | case RECENCY_STYLE_LARGE_RING: 23 | return (RecencyStyle) {.font = FONT_28_BOLD, .diameter = 29, .inset = 7}; 24 | case RECENCY_STYLE_LARGE_NO_CIRCLE: 25 | default: 26 | return (RecencyStyle) {.font = FONT_28_BOLD, .diameter = 19, .inset = 0}; 27 | } 28 | } 29 | 30 | uint16_t recency_component_height() { 31 | return get_style().diameter + 2 * recency_component_padding(); 32 | } 33 | 34 | uint16_t recency_component_padding() { 35 | return 1; 36 | } 37 | 38 | static void draw_text(Layer *layer, GContext *ctx, int32_t seconds, bool has_circle, RecencyProps *props) { 39 | static char string[16]; 40 | format_recency(string, 16, seconds); 41 | 42 | GRect bounds = layer_get_bounds(layer); 43 | RecencyStyle style = get_style(); 44 | FontChoice font = get_font(style.font); 45 | 46 | int16_t text_width; 47 | GTextAlignment alignment; 48 | int16_t content_width = graphics_text_layout_get_content_size(string, fonts_get_system_font(font.key), bounds, GTextOverflowModeWordWrap, GTextAlignmentLeft).w; 49 | if (content_width <= style.diameter) { 50 | text_width = style.diameter; 51 | alignment = GTextAlignmentCenter; 52 | } else { 53 | text_width = content_width; 54 | alignment = GTextAlignmentLeft; 55 | } 56 | 57 | int16_t text_height = font.padding_top + font.height + font.padding_bottom; 58 | int16_t text_x = props->align_right ? bounds.size.w - text_width : 0; 59 | int16_t text_y = (bounds.size.h - font.height) / 2 - font.padding_top; 60 | 61 | GRect text_bounds = (GRect) { 62 | .origin = GPoint(text_x, text_y), 63 | .size = GSize(text_width, text_height), 64 | }; 65 | 66 | GSize new_size; 67 | if (!has_circle) { 68 | // Draw a background rectangle for legibility 69 | graphics_context_set_fill_color(ctx, props->parent_bg); 70 | graphics_fill_rect(ctx, grect_inset(text_bounds, GEdgeInsets(-1)), 0, GCornerNone); 71 | new_size = GSize(text_bounds.size.w + 1, text_bounds.size.h + 1); 72 | } else { 73 | new_size = GSize(style.diameter, style.diameter); 74 | } 75 | 76 | graphics_context_set_text_color(ctx, COLOR_FALLBACK(get_prefs()->colors[COLOR_KEY_RECENCY_TEXT], props->parent_fg)); 77 | graphics_draw_text(ctx, string, fonts_get_system_font(font.key), text_bounds, GTextOverflowModeWordWrap, alignment, NULL); 78 | 79 | if (props->size_changed_callback != NULL) { 80 | props->size_changed_callback(new_size, props->size_changed_context); 81 | } 82 | } 83 | 84 | static void draw_circle_and_text(Layer *layer, GContext *ctx) { 85 | if (last_data_message() == NULL) { 86 | return; 87 | } 88 | 89 | RecencyStyle style = get_style(); 90 | RecencyProps *props = layer_get_data(layer); 91 | 92 | bool align_right = props->align_right; 93 | GRect circle_bounds = (GRect) { 94 | .origin = GPoint(align_right ? layer_get_bounds(layer).size.w - style.diameter : 0, 0), 95 | .size = GSize(style.diameter, style.diameter), 96 | }; 97 | 98 | int32_t seconds = time(NULL) - last_data_message()->received_at + last_data_message()->recency; 99 | int32_t minutes = seconds / 60; 100 | if (seconds - minutes * 60 >= 30) { 101 | minutes += 1; 102 | } 103 | 104 | int32_t start = 360 * (minutes - 1) / 5; 105 | int32_t end = 360; 106 | bool draw_circle = minutes < 10; 107 | 108 | if (draw_circle) { 109 | graphics_context_set_fill_color(ctx, props->parent_bg); 110 | graphics_fill_radial(ctx, circle_bounds, GOvalScaleModeFitCircle, style.diameter, DEG_TO_TRIGANGLE(0), DEG_TO_TRIGANGLE(360)); 111 | 112 | graphics_context_set_fill_color(ctx, COLOR_FALLBACK(get_prefs()->colors[COLOR_KEY_RECENCY_CIRCLE], GColorLightGray)); 113 | graphics_fill_radial(ctx, circle_bounds, GOvalScaleModeFitCircle, style.inset, DEG_TO_TRIGANGLE(start), DEG_TO_TRIGANGLE(end)); 114 | } 115 | 116 | draw_text(layer, ctx, seconds, draw_circle, props); 117 | } 118 | 119 | RecencyComponent* recency_component_create(Layer *parent, uint16_t y, bool align_right, void (*size_changed_callback)(GSize, void*), void *size_changed_context) { 120 | RecencyComponent *c = malloc(sizeof(RecencyComponent)); 121 | 122 | c->circle_layer = layer_create_with_data( 123 | GRect( 124 | recency_component_padding(), 125 | y + recency_component_padding(), 126 | element_get_bounds(parent).size.w - recency_component_padding() * 2, 127 | get_style().diameter 128 | ), 129 | sizeof(RecencyProps) 130 | ); 131 | 132 | RecencyProps *props = layer_get_data(c->circle_layer); 133 | props->align_right = align_right; 134 | props->parent_bg = element_bg(parent); 135 | props->parent_fg = element_fg(parent); 136 | props->size_changed_callback = size_changed_callback; 137 | props->size_changed_context = size_changed_context; 138 | 139 | layer_set_update_proc(c->circle_layer, draw_circle_and_text); 140 | layer_add_child(parent, c->circle_layer); 141 | 142 | return c; 143 | } 144 | 145 | void recency_component_destroy(RecencyComponent *c) { 146 | layer_destroy(c->circle_layer); 147 | free(c); 148 | } 149 | 150 | void recency_component_tick(RecencyComponent *c) { 151 | layer_mark_dirty(c->circle_layer); 152 | } 153 | -------------------------------------------------------------------------------- /src/layout.c: -------------------------------------------------------------------------------- 1 | #include "fonts.h" 2 | #include "layout.h" 3 | 4 | static uint8_t s_num_elements; 5 | static Layer** s_layers; 6 | static GSize *s_pixel_sizes; 7 | static TextLayer* s_need_prefs_message; 8 | 9 | GColor element_bg(Layer* layer) { 10 | return get_element_data(layer)->black ? GColorBlack : GColorWhite; 11 | } 12 | 13 | GColor element_fg(Layer* layer) { 14 | return get_element_data(layer)->black ? GColorWhite : GColorBlack; 15 | } 16 | 17 | GCompOp element_comp_op(Layer* layer) { 18 | return get_element_data(layer)->black ? GCompOpSet : GCompOpAnd; 19 | } 20 | 21 | static Layer* get_layer_for_element(uint8_t element) { 22 | for(uint8_t i = 0; i < get_prefs()->num_elements; i++) { 23 | if(get_element_data(s_layers[i])->el == element) { 24 | return s_layers[i]; 25 | } 26 | } 27 | return NULL; 28 | } 29 | 30 | static void draw_background_and_borders(Layer *layer, GContext *ctx) { 31 | GRect bounds = layer_get_bounds(layer); 32 | if (get_element_data(layer)->black) { 33 | graphics_context_set_fill_color(ctx, element_bg(layer)); 34 | graphics_fill_rect(ctx, GRect(0, 0, bounds.size.w, bounds.size.h), 0, GCornerNone); 35 | } 36 | graphics_context_set_stroke_color(ctx, element_fg(layer)); 37 | if (get_element_data(layer)->bottom) { 38 | graphics_draw_line(ctx, GPoint(0, bounds.size.h - 1), GPoint(bounds.size.w - 1, bounds.size.h - 1)); 39 | } 40 | if (get_element_data(layer)->right) { 41 | graphics_draw_line(ctx, GPoint(bounds.size.w - 1, 0), GPoint(bounds.size.w - 1, bounds.size.h - 1)); 42 | } 43 | } 44 | 45 | static Layer* position_layer(Layer *parent, GPoint *pos, ElementConfig config, GSize size, bool actually_make_layer) { 46 | Layer *layer = NULL; 47 | GSize parent_size = layer_get_bounds(parent).size; 48 | 49 | uint8_t width; 50 | if (size.w == 0) { 51 | width = parent_size.w - pos->x; 52 | } else { 53 | width = size.w; 54 | } 55 | width += config.right; 56 | uint8_t height = size.h + config.bottom; 57 | 58 | if (actually_make_layer) { 59 | layer = layer_create_with_data( 60 | GRect(pos->x, pos->y, width, height), 61 | sizeof(ElementConfig) 62 | ); 63 | memcpy(get_element_data(layer), &config, sizeof(ElementConfig)); 64 | layer_add_child(parent, layer); 65 | layer_set_update_proc(layer, draw_background_and_borders); 66 | } 67 | 68 | pos->x += width; 69 | if (pos->x >= parent_size.w) { 70 | pos->x = 0; 71 | pos->y += height; 72 | } 73 | 74 | return layer; 75 | } 76 | 77 | static void compute_pixel_sizes(GSize *result, Layer *parent, ElementConfig *elements) { 78 | GSize screen_size = layer_get_bounds(parent).size; 79 | for(uint8_t i = 0; i < s_num_elements; i++) { 80 | // division without floats 81 | result[i].h = screen_size.h * elements[i].h / 100; 82 | if (screen_size.h * elements[i].h / 10 - (10 * result[i].h) >= 5) { 83 | result[i].h += 1; 84 | } 85 | result[i].w = screen_size.w * elements[i].w / 100; 86 | if (screen_size.w * elements[i].w / 10 - (10 * result[i].w) >= 5) { 87 | result[i].w += 1; 88 | } 89 | } 90 | } 91 | 92 | static uint8_t compute_auto_height(Layer *parent) { 93 | GPoint pos = {.x = 0, .y = 0}; 94 | uint8_t num_rows_auto_height = 0; 95 | for(uint8_t i = 0; i < s_num_elements; i++) { 96 | num_rows_auto_height += (pos.x == 0 && get_prefs()->elements[i].h == 0); 97 | position_layer(parent, &pos, get_prefs()->elements[i], s_pixel_sizes[i], false); 98 | } 99 | uint8_t total_height = layer_get_bounds(parent).size.h; 100 | uint8_t remaining_height = total_height - pos.y; 101 | 102 | return remaining_height / num_rows_auto_height; 103 | } 104 | 105 | 106 | ElementConfig* get_element_data(Layer* layer) { 107 | return (ElementConfig*)layer_get_data(layer); 108 | } 109 | 110 | GRect element_get_bounds(Layer* layer) { 111 | GRect bounds = layer_get_bounds(layer); 112 | ElementConfig *config = get_element_data(layer); 113 | if (config->bottom) { 114 | bounds.size.h--; 115 | } 116 | if (config->right) { 117 | bounds.size.w--; 118 | } 119 | return bounds; 120 | } 121 | 122 | static TextLayer* maybe_create_need_prefs_message(Layer* parent) { 123 | if (s_num_elements > 0) { 124 | return NULL; 125 | } else { 126 | TextLayer *t = add_text_layer( 127 | parent, 128 | layer_get_bounds(parent), 129 | fonts_get_system_font(get_font(FONT_28_BOLD).key), 130 | GColorBlack, 131 | GTextAlignmentCenter 132 | ); 133 | text_layer_set_text(t, "Urchin CGM\n\nWaiting for settings from phone..."); 134 | return t; 135 | } 136 | } 137 | 138 | LayoutLayers init_layout(Window* window) { 139 | s_num_elements = get_prefs()->num_elements; 140 | s_layers = malloc(s_num_elements * sizeof(Layer*)); 141 | s_pixel_sizes = malloc(s_num_elements * sizeof(GSize)); 142 | 143 | Layer *window_layer = window_get_root_layer(window); 144 | 145 | compute_pixel_sizes(s_pixel_sizes, window_layer, get_prefs()->elements); 146 | 147 | uint8_t auto_height = compute_auto_height(window_layer); 148 | for(uint8_t i = 0; i < get_prefs()->num_elements; i++) { 149 | if (s_pixel_sizes[i].h == 0) { 150 | s_pixel_sizes[i].h = auto_height; 151 | } 152 | } 153 | 154 | GPoint pos = {.x = 0, .y = 0}; 155 | for(uint8_t i = 0; i < get_prefs()->num_elements; i++) { 156 | s_layers[i] = position_layer(window_layer, &pos, get_prefs()->elements[i], s_pixel_sizes[i], true); 157 | } 158 | 159 | s_need_prefs_message = maybe_create_need_prefs_message(window_layer); 160 | 161 | return (LayoutLayers) { 162 | .graph = get_layer_for_element(GRAPH_ELEMENT), 163 | .sidebar = get_layer_for_element(SIDEBAR_ELEMENT), 164 | .status_bar = get_layer_for_element(STATUS_BAR_ELEMENT), 165 | .time_area = get_layer_for_element(TIME_AREA_ELEMENT), 166 | .bg_row = get_layer_for_element(BG_ROW_ELEMENT), 167 | }; 168 | } 169 | 170 | void deinit_layout() { 171 | for(uint8_t i = 0; i < s_num_elements; i++) { 172 | layer_destroy(s_layers[i]); 173 | } 174 | if (s_need_prefs_message != NULL) { 175 | text_layer_destroy(s_need_prefs_message); 176 | } 177 | free(s_layers); 178 | free(s_pixel_sizes); 179 | } 180 | 181 | TextLayer* add_text_layer(Layer *parent, GRect bounds, GFont font, GColor fg_color, GTextAlignment alignment) { 182 | TextLayer *t = text_layer_create(bounds); 183 | text_layer_set_font(t, font); 184 | text_layer_set_background_color(t, GColorClear); 185 | text_layer_set_text_color(t, fg_color); 186 | text_layer_set_text_alignment(t, alignment); 187 | layer_add_child(parent, text_layer_get_layer(t)); 188 | return t; 189 | } 190 | -------------------------------------------------------------------------------- /src/js/format.js: -------------------------------------------------------------------------------- 1 | /* jshint browser: true */ 2 | /* global module */ 3 | 4 | var format = function(c) { 5 | var f = {}; 6 | 7 | f.sgvArray = function(endTime, sgvs, maxSGVs) { 8 | var noEntry = { 9 | 'date': Infinity, 10 | 'sgv': 0 11 | }; 12 | var i; 13 | 14 | var graphed = []; 15 | var xs = []; 16 | for(i = 0; i < maxSGVs * c.INTERVAL_SIZE_SECONDS; i += c.INTERVAL_SIZE_SECONDS) { 17 | graphed.push(noEntry); 18 | xs.push(endTime - i * 1000); 19 | } 20 | 21 | for(i = 0; i < sgvs.length; i++) { 22 | var min = Infinity; 23 | var xi; 24 | // Don't graph missing sgvs or error codes 25 | if(sgvs[i]['sgv'] === undefined || sgvs[i]['sgv'] <= c.DEXCOM_ERROR_CODE_MAX) { 26 | continue; 27 | } 28 | // Find the x value closest to this sgv's date 29 | for(var j = 0; j < xs.length; j++) { 30 | if(Math.abs(sgvs[i]['date'] - xs[j]) < min) { 31 | min = Math.abs(sgvs[i]['date'] - xs[j]); 32 | xi = j; 33 | } 34 | } 35 | // Assign it if it's the closest sgv to that x 36 | if(min < c.INTERVAL_SIZE_SECONDS * 1000 && Math.abs(sgvs[i]['date'] - xs[xi]) < Math.abs(graphed[xi]['date'] - xs[xi])) { 37 | graphed[xi] = sgvs[i]; 38 | } 39 | } 40 | 41 | var ys = graphed.map(function(entry) { return entry['sgv']; }); 42 | 43 | return ys; 44 | }; 45 | 46 | function _graphIntervals(endTime, maxSGVs) { 47 | var out = []; 48 | for(var i = 0; i < maxSGVs * c.INTERVAL_SIZE_SECONDS; i += c.INTERVAL_SIZE_SECONDS) { 49 | out.push({ 50 | start: endTime - 1000 * i - 1000 * c.INTERVAL_SIZE_SECONDS / 2, 51 | end: endTime - 1000 * i + 1000 * c.INTERVAL_SIZE_SECONDS / 2, 52 | }); 53 | } 54 | return out; 55 | } 56 | 57 | f.bolusGraphArray = function(endTime, bolusHistory, maxSGVs) { 58 | return _graphIntervals(endTime, maxSGVs).map(function(interval) { 59 | var bolusInInterval = false; 60 | for(var j = 0; j < bolusHistory.length; j++) { 61 | var bolusTime = new Date(bolusHistory[j]['created_at']).getTime(); 62 | if (interval.start < bolusTime && bolusTime < interval.end) { 63 | bolusInInterval = true; 64 | } 65 | } 66 | return bolusInInterval ? 1 : 0; 67 | }); 68 | }; 69 | 70 | f.basalRateArray = function(endTime, basalHistory, maxSGVs) { 71 | return _graphIntervals(endTime, maxSGVs).map(function(interval) { 72 | var rateTotals = {}; 73 | basalHistory.forEach(function(basal, i) { 74 | if (i === basalHistory.length - 1) { 75 | return; 76 | } 77 | 78 | var next = basalHistory[i + 1]; 79 | if (next.start <= interval.start || basal.start > interval.end) { 80 | return; 81 | } 82 | 83 | var duration; 84 | if (basal.start >= interval.start && next.start <= interval.end) { 85 | duration = next.start - basal.start; 86 | } else if (basal.start < interval.start && next.start <= interval.end) { 87 | duration = next.start - interval.start; 88 | } else if (basal.start >= interval.start && next.start > interval.end) { 89 | duration = interval.end - basal.start; 90 | } else { 91 | duration = interval.end - interval.start; 92 | } 93 | rateTotals[basal.absolute] = (basal.absolute in rateTotals ? rateTotals[basal.absolute] : 0) + duration; 94 | }); 95 | 96 | if (Object.keys(rateTotals).length === 0) { 97 | return 0; 98 | } else { 99 | return parseFloat( 100 | Object.keys(rateTotals).reduce(function(acc, rate) { 101 | return rateTotals[rate] > rateTotals[acc] ? rate : acc; 102 | }) 103 | ); 104 | } 105 | }); 106 | }; 107 | 108 | function _rescale(arr, pixelHeight) { 109 | var max = Math.max.apply(Math, arr); 110 | return arr.map(function(x) { 111 | return Math.round(x / max * pixelHeight); 112 | }); 113 | } 114 | 115 | f.basalGraphArray = function(endTime, basalHistory, maxSGVs, config) { 116 | return _rescale(f.basalRateArray(endTime, basalHistory, maxSGVs), config.basalHeight); 117 | }; 118 | 119 | function _encodeBits(value, offset, bits) { 120 | return Math.min(value, Math.pow(2, bits) - 1) << offset; 121 | } 122 | 123 | f.graphExtraArray = function(boluses, basals) { 124 | var out = []; 125 | var bits; 126 | for(var i = 0; i < boluses.length; i++) { 127 | // See GraphExtra in graph_element.c 128 | bits = 0; 129 | bits += _encodeBits(boluses[i], c.GRAPH_EXTRA_BOLUS_OFFSET, c.GRAPH_EXTRA_BOLUS_BITS); 130 | bits += _encodeBits(basals[i], c.GRAPH_EXTRA_BASAL_OFFSET, c.GRAPH_EXTRA_BASAL_BITS); 131 | out.push(bits); 132 | } 133 | return out; 134 | }; 135 | 136 | f.lastTrendNumber = function(sgvs) { 137 | if (sgvs.length === 0) { 138 | return 0; 139 | } 140 | 141 | var trend = sgvs[0]['trend']; 142 | if (!isNaN(parseInt(trend)) && trend >= 0 && trend <= 9) { 143 | return trend; 144 | } else if (sgvs[0]['direction'] !== undefined) { 145 | return c.NIGHTSCOUT_DIRECTION_TO_TREND[sgvs[0]['direction']] || 0; 146 | } else { 147 | return 0; 148 | } 149 | }; 150 | 151 | f.lastSgv = function(sgvs) { 152 | if (sgvs.length === 0) { 153 | return 0; 154 | } 155 | var last = parseInt(sgvs[0]['sgv'], 10); 156 | return last <= c.DEXCOM_ERROR_CODE_MAX ? 0 : last; 157 | }; 158 | 159 | f.lastDelta = function(ys) { 160 | if (ys[1] === 0) { 161 | return c.NO_DELTA_VALUE; 162 | } else { 163 | return ys[0] - ys[1]; 164 | } 165 | }; 166 | 167 | f.recency = function(sgvs) { 168 | if (sgvs.length === 0) { 169 | // TODO 170 | return 999 * 60 * 60; 171 | } else { 172 | var seconds = (Date.now() - sgvs[0]['date']) / 1000; 173 | return Math.floor(seconds); 174 | } 175 | }; 176 | 177 | f.predictions = function(predictedBGs, maxLength) { 178 | if (!predictedBGs.series) { 179 | predictedBGs.series = []; 180 | } 181 | 182 | var recency; 183 | if (predictedBGs.startDate) { 184 | recency = Math.round((Date.now() - predictedBGs.startDate) / 1000); 185 | } 186 | 187 | var formattedSeries = predictedBGs.series.map(function(series) { 188 | return series.slice(0, maxLength); 189 | }).map(function(series) { 190 | return series.map(function(y) { 191 | // 0 == "no BG" 192 | return Math.max(1, Math.min(255, Math.floor(y / 2))); 193 | }); 194 | }); 195 | 196 | return { 197 | series1: formattedSeries[0], 198 | series2: formattedSeries[1], 199 | series3: formattedSeries[2], 200 | recency: recency, 201 | }; 202 | }; 203 | 204 | return f; 205 | }; 206 | 207 | module.exports = format; 208 | -------------------------------------------------------------------------------- /src/connection_status_component.c: -------------------------------------------------------------------------------- 1 | #include "comm.h" 2 | #include "connection_status_component.h" 3 | #include "fonts.h" 4 | #include "layout.h" 5 | #include "staleness.h" 6 | 7 | #define REASON_ICON_WIDTH 25 8 | #define TEXT_V_MARGIN 1 9 | #define REQUEST_STATE_MESSAGE_DURATION_MS 5000 10 | #define CONN_STATUS_FONT FONT_18_BOLD 11 | 12 | // This matches STALENESS_REASON_* 13 | const uint32_t CONN_ISSUE_ICONS[] = { 14 | 0, 15 | RESOURCE_ID_CONN_ISSUE_BLUETOOTH, 16 | RESOURCE_ID_CONN_ISSUE_NETWORK, 17 | RESOURCE_ID_CONN_ISSUE_RIG, 18 | }; 19 | 20 | // This matches REQUEST_STATE_* 21 | const char* REQUEST_STATE_MESSAGES[] = { 22 | "", 23 | "", 24 | "", 25 | "Bad app msg", 26 | "Timed out", 27 | "No BT", 28 | "Msg failed", 29 | "Msg dropped", 30 | "Begin failed", 31 | "Send failed", 32 | "Open failed", 33 | }; 34 | 35 | ConnectionStatusComponent* connection_status_component_create(Layer *parent, int16_t x, int16_t y, bool align_bottom) { 36 | BitmapLayer *icon_layer = bitmap_layer_create(GRect(x, y, REASON_ICON_WIDTH, REASON_ICON_WIDTH)); 37 | // draw the icon background over the graph 38 | bitmap_layer_set_compositing_mode(icon_layer, get_element_data(parent)->black ? GCompOpAssignInverted : GCompOpAssign); 39 | layer_set_hidden(bitmap_layer_get_layer(icon_layer), true); 40 | layer_add_child(parent, bitmap_layer_get_layer(icon_layer)); 41 | 42 | GRect parent_bounds = element_get_bounds(parent); 43 | 44 | // The text appears only when there's an issue, and expands to fit content 45 | TextLayer *reason_text = add_text_layer( 46 | parent, 47 | parent_bounds, 48 | fonts_get_system_font(get_font(CONN_STATUS_FONT).key), 49 | element_fg(parent), 50 | GTextAlignmentRight 51 | ); 52 | text_layer_set_background_color(reason_text, element_bg(parent)); 53 | 54 | ConnectionStatusComponent *c = malloc(sizeof(ConnectionStatusComponent)); 55 | c->icon_layer = icon_layer; 56 | c->icon_bitmap = NULL; 57 | c->reason_text = reason_text; 58 | c->background = element_bg(parent); 59 | c->align_bottom = align_bottom; 60 | c->parent_bounds = parent_bounds; 61 | c->initial_x = x; 62 | c->initial_y = y; 63 | if (comm_is_update_in_progress()) { 64 | c->is_showing_request_state = true; 65 | connection_status_component_show_request_state(c, REQUEST_STATE_WAITING, 0); 66 | } else { 67 | c->is_showing_request_state = false; 68 | } 69 | return c; 70 | } 71 | 72 | void connection_status_component_update_offset(ConnectionStatusComponent* c, GSize size) { 73 | GRect icon_frame = layer_get_frame(bitmap_layer_get_layer(c->icon_layer)); 74 | layer_set_frame( 75 | bitmap_layer_get_layer(c->icon_layer), 76 | GRect(c->initial_x + size.w, c->initial_y + size.h, icon_frame.size.w, icon_frame.size.h) 77 | ); 78 | } 79 | 80 | void connection_status_component_destroy(ConnectionStatusComponent *c) { 81 | text_layer_destroy(c->reason_text); 82 | if (c->icon_bitmap != NULL) { 83 | gbitmap_destroy(c->icon_bitmap); 84 | } 85 | bitmap_layer_destroy(c->icon_layer); 86 | free(c); 87 | } 88 | 89 | static void _resize_text_frame(ConnectionStatusComponent *c, int16_t width, int16_t height, bool fill_background) { 90 | // Make the background transparent during the resizing to avoid a flash 91 | text_layer_set_background_color(c->reason_text, fill_background ? c->background : GColorClear); 92 | 93 | layer_set_frame( 94 | text_layer_get_layer(c->reason_text), 95 | GRect( 96 | c->parent_bounds.size.w - width, 97 | c->align_bottom ? c->parent_bounds.size.h - height - TEXT_V_MARGIN : -get_font(CONN_STATUS_FONT).padding_top + TEXT_V_MARGIN, 98 | width, 99 | height 100 | ) 101 | ); 102 | } 103 | 104 | static void _trim_text_frame(void *callback_data) { 105 | ConnectionStatusComponent *c = callback_data; 106 | _resize_text_frame( 107 | c, 108 | text_layer_get_content_size(c->reason_text).w, 109 | text_layer_get_content_size(c->reason_text).h, 110 | true 111 | ); 112 | } 113 | 114 | static void fix_text_frame(ConnectionStatusComponent *c) { 115 | // XXX: need this on Basalt, but not on Aplite or emulator 116 | _resize_text_frame(c, c->parent_bounds.size.w, c->parent_bounds.size.h, false); 117 | layer_mark_dirty(text_layer_get_layer(c->reason_text)); 118 | app_timer_register(100, _trim_text_frame, c); 119 | } 120 | 121 | static void clear_request_state(void *callback_data) { 122 | ConnectionStatusComponent *c = callback_data; 123 | c->is_showing_request_state = false; 124 | connection_status_component_tick(c); 125 | } 126 | 127 | void connection_status_component_tick(ConnectionStatusComponent *c) { 128 | if (c->is_showing_request_state) { 129 | return; 130 | } 131 | 132 | layer_set_hidden(text_layer_get_layer(c->reason_text), true); 133 | 134 | ConnectionIssue issue = connection_issue(); 135 | if (issue.reason == CONNECTION_ISSUE_NONE) { 136 | layer_set_hidden(bitmap_layer_get_layer(c->icon_layer), true); 137 | } else { 138 | layer_set_hidden(bitmap_layer_get_layer(c->icon_layer), false); 139 | if (c->icon_bitmap != NULL) { 140 | gbitmap_destroy(c->icon_bitmap); 141 | } 142 | c->icon_bitmap = gbitmap_create_with_resource(CONN_ISSUE_ICONS[issue.reason]); 143 | bitmap_layer_set_bitmap(c->icon_layer, c->icon_bitmap); 144 | } 145 | } 146 | 147 | void connection_status_component_show_request_state(ConnectionStatusComponent *c, RequestState state, AppMessageResult reason) { 148 | if (state == REQUEST_STATE_SUCCESS) { 149 | clear_request_state(c); 150 | return; 151 | } 152 | 153 | c->is_showing_request_state = true; 154 | 155 | layer_set_hidden(bitmap_layer_get_layer(c->icon_layer), false); 156 | if (c->icon_bitmap != NULL) { 157 | gbitmap_destroy(c->icon_bitmap); 158 | } 159 | if (state == REQUEST_STATE_FETCH_ERROR) { 160 | c->icon_bitmap = gbitmap_create_with_resource(RESOURCE_ID_CONN_ISSUE_NETWORK); 161 | } else { 162 | c->icon_bitmap = gbitmap_create_with_resource(RESOURCE_ID_CONN_REFRESHING); 163 | } 164 | bitmap_layer_set_bitmap(c->icon_layer, c->icon_bitmap); 165 | 166 | if (state == REQUEST_STATE_WAITING || state == REQUEST_STATE_FETCH_ERROR) { 167 | layer_set_hidden(text_layer_get_layer(c->reason_text), true); 168 | } else { 169 | layer_set_hidden(text_layer_get_layer(c->reason_text), false); 170 | 171 | static char state_text[32]; 172 | strcpy(state_text, REQUEST_STATE_MESSAGES[state]); 173 | 174 | if (reason != 0) { 175 | static char reason_text[16]; 176 | snprintf(reason_text, sizeof(reason_text), "\nCode %d", reason); 177 | strcat(state_text, reason_text); 178 | } 179 | 180 | text_layer_set_text(c->reason_text, state_text); 181 | fix_text_frame(c); 182 | } 183 | 184 | if (state != REQUEST_STATE_WAITING) { 185 | app_timer_register(REQUEST_STATE_MESSAGE_DURATION_MS, clear_request_state, c); 186 | } 187 | } 188 | 189 | uint16_t connection_status_component_size() { 190 | return REASON_ICON_WIDTH; 191 | } 192 | -------------------------------------------------------------------------------- /src/app_messages.c: -------------------------------------------------------------------------------- 1 | #include "app_messages.h" 2 | 3 | // Exclude logging on Aplite 4 | #ifdef DEBUG 5 | #ifndef PBL_PLATFORM_APLITE 6 | static const char* type_name(TupleType type) { 7 | switch(type) { 8 | case TUPLE_BYTE_ARRAY: return "byte array"; 9 | case TUPLE_CSTRING: return "cstring"; 10 | case TUPLE_UINT: return "uint"; 11 | case TUPLE_INT: return "int"; 12 | default: return ""; 13 | } 14 | } 15 | #endif 16 | #endif 17 | 18 | static bool fail_unexpected_type(uint8_t key, TupleType type, const char* expected) { 19 | #ifdef DEBUG 20 | #ifndef PBL_PLATFORM_APLITE 21 | APP_LOG(APP_LOG_LEVEL_ERROR, "Expected key %d to have type %s, but has type %s", (int)key, expected, type_name(type)); 22 | #endif 23 | #endif 24 | return false; 25 | } 26 | 27 | static bool fail_missing_required_value(uint8_t key) { 28 | #ifdef DEBUG 29 | #ifndef PBL_PLATFORM_APLITE 30 | APP_LOG(APP_LOG_LEVEL_ERROR, "Missing required value for key %d", (int)key); 31 | #endif 32 | #endif 33 | return false; 34 | } 35 | 36 | static bool pass_default_value(uint8_t key, const char * type) { 37 | #ifdef DEBUG 38 | #ifndef PBL_PLATFORM_APLITE 39 | APP_LOG(APP_LOG_LEVEL_DEBUG, "Missing %s value for key %d, assigning default", type, (int)key); 40 | #endif 41 | #endif 42 | return true; 43 | } 44 | 45 | bool get_int32(DictionaryIterator *data, int32_t *dest, uint8_t key, bool required, int32_t fallback) { 46 | Tuple *t = dict_find(data, key); 47 | if (t != NULL) { 48 | if (t->type == TUPLE_INT) { 49 | switch(t->length) { 50 | case 1: *dest = t->value->int8; break; 51 | case 2: *dest = t->value->int16; break; 52 | default: *dest = t->value->int32; break; 53 | } 54 | return true; 55 | } else if (t->type == TUPLE_UINT) { 56 | switch(t->length) { 57 | case 1: *dest = t->value->uint8; break; 58 | case 2: *dest = t->value->uint16; break; 59 | default: *dest = t->value->uint32; break; 60 | } 61 | return true; 62 | } else { 63 | return fail_unexpected_type(key, t->type, "int or uint"); 64 | } 65 | } else { 66 | if (required) { 67 | return fail_missing_required_value(key); 68 | } else { 69 | *dest = fallback; 70 | return pass_default_value(key, "int32"); 71 | } 72 | } 73 | } 74 | 75 | bool get_byte_array(DictionaryIterator *data, uint8_t *dest, uint8_t key, size_t max_length, bool required, uint8_t *fallback) { 76 | Tuple *t = dict_find(data, key); 77 | if (t != NULL) { 78 | if (t->type == TUPLE_BYTE_ARRAY) { 79 | memcpy(dest, t->value->data, (t->length < max_length ? t->length : max_length) * sizeof(uint8_t)); 80 | return true; 81 | } else { 82 | return fail_unexpected_type(key, t->type, "byte array"); 83 | } 84 | } else { 85 | if (required) { 86 | return fail_missing_required_value(key); 87 | } else { 88 | memcpy(dest, fallback, max_length * sizeof(uint8_t)); 89 | return pass_default_value(key, "byte array"); 90 | } 91 | } 92 | } 93 | 94 | bool get_byte_array_length(DictionaryIterator *data, uint16_t *dest, uint16_t max_length, uint8_t key) { 95 | // assumes get_byte_array has already succeeded for this key 96 | uint16_t length = dict_find(data, key)->length; 97 | if (max_length == 0) { 98 | *dest = length; 99 | } else { 100 | *dest = length > max_length ? max_length : length; 101 | } 102 | return true; 103 | } 104 | 105 | bool get_cstring(DictionaryIterator *data, char *dest, uint8_t key, size_t max_length, bool required, const char* fallback) { 106 | Tuple *t = dict_find(data, key); 107 | if (t != NULL) { 108 | if (t->type == TUPLE_CSTRING) { 109 | strncpy(dest, t->value->cstring, max_length); 110 | return true; 111 | } else { 112 | return fail_unexpected_type(key, t->type, "cstring"); 113 | } 114 | } else { 115 | if (required) { 116 | return fail_missing_required_value(key); 117 | } else { 118 | strcpy(dest, fallback); 119 | return pass_default_value(key, "cstring"); 120 | } 121 | } 122 | } 123 | 124 | static void get_prediction(DictionaryIterator *data, uint8_t *dest, uint8_t key, uint8_t *max_length) { 125 | Tuple *t = dict_find(data, key); 126 | if (t && t->type == TUPLE_BYTE_ARRAY) { 127 | uint8_t length = t->length < PREDICTION_MAX_LENGTH ? t->length : PREDICTION_MAX_LENGTH; 128 | memcpy(dest, t->value->data, length); 129 | *max_length = length > *max_length ? length : *max_length; 130 | } 131 | } 132 | 133 | bool validate_data_message(DictionaryIterator *data, DataMessage *out) { 134 | /* 135 | * Validation is not necessary for messages from the PebbleKit JS half of 136 | * Urchin since it is distributed with the C SDK half, but other clients 137 | * (Pancreabble, Loop, xDrip(?)) are not guaranteed to be using exactly the 138 | * same message format as this version of Urchin. 139 | */ 140 | static uint8_t zeroes[GRAPH_MAX_SGV_COUNT]; 141 | memset(zeroes, 0, GRAPH_MAX_SGV_COUNT); 142 | 143 | out->received_at = time(NULL); 144 | 145 | // If the series are of different lengths, pad the shorter ones with zeroes 146 | memcpy(out->prediction_1, zeroes, PREDICTION_MAX_LENGTH * sizeof(uint8_t)); 147 | memcpy(out->prediction_2, zeroes, PREDICTION_MAX_LENGTH * sizeof(uint8_t)); 148 | memcpy(out->prediction_3, zeroes, PREDICTION_MAX_LENGTH * sizeof(uint8_t)); 149 | 150 | bool success = true 151 | && get_int32(data, &out->recency, MESSAGE_KEY_recency, false, 0) 152 | && get_byte_array(data, out->sgvs, MESSAGE_KEY_sgvs, GRAPH_MAX_SGV_COUNT, true, NULL) 153 | && get_byte_array_length(data, &out->sgv_count, GRAPH_MAX_SGV_COUNT, MESSAGE_KEY_sgvs) 154 | && get_int32(data, &out->last_sgv, MESSAGE_KEY_lastSgv, true, 0) 155 | && get_int32(data, &out->trend, MESSAGE_KEY_trend, false, 0) 156 | && get_int32(data, &out->delta, MESSAGE_KEY_delta, false, NO_DELTA_VALUE) 157 | && get_cstring(data, out->status_text, MESSAGE_KEY_statusText, STATUS_BAR_MAX_LENGTH, false, "") 158 | && get_int32(data, &out->status_recency, MESSAGE_KEY_statusRecency, false, -1) 159 | && get_byte_array(data, (uint8_t*)out->graph_extra, MESSAGE_KEY_graphExtra, GRAPH_MAX_SGV_COUNT, false, zeroes); 160 | 161 | out->prediction_length = 0; 162 | get_prediction(data, out->prediction_1, MESSAGE_KEY_prediction1, &out->prediction_length); 163 | get_prediction(data, out->prediction_2, MESSAGE_KEY_prediction2, &out->prediction_length); 164 | get_prediction(data, out->prediction_3, MESSAGE_KEY_prediction3, &out->prediction_length); 165 | if (out->prediction_length > 0) { 166 | success = success && get_int32(data, &out->prediction_recency, MESSAGE_KEY_predictionRecency, false, 0); 167 | } 168 | 169 | return success; 170 | } 171 | 172 | static DataMessage *_last_data_message = NULL; 173 | 174 | void save_last_data_message(DataMessage *d) { 175 | _last_data_message = d; 176 | } 177 | 178 | DataMessage *last_data_message() { 179 | return _last_data_message; 180 | } 181 | -------------------------------------------------------------------------------- /src/comm.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include "config.h" 3 | #include "comm.h" 4 | #include "preferences.h" 5 | 6 | static bool phone_contact = false; 7 | static bool update_in_progress; 8 | static AppTimer *request_timer = NULL; 9 | static AppTimer *timeout_timer = NULL; 10 | 11 | static void (*data_callback)(DataMessage *data); 12 | static void (*prefs_callback)(DictionaryIterator *received); 13 | static void (*request_state_callback)(RequestState state, AppMessageResult reason); 14 | static void schedule_update(uint32_t delay); 15 | static void request_update(); 16 | static void timeout_handler(); 17 | static void init_app_message(); 18 | 19 | static DataMessage *_last_data_message; 20 | 21 | static void clear_timer(AppTimer **timer) { 22 | if (*timer != NULL) { 23 | app_timer_cancel(*timer); 24 | *timer = NULL; 25 | } 26 | } 27 | 28 | static uint32_t timeout_length() { 29 | // Start with extra short timeouts on launch to get data showing as soon as possible. 30 | static uint32_t exponential_timeout = INITIAL_TIMEOUT_HALVED; 31 | 32 | if (phone_contact) { 33 | return DEFAULT_TIMEOUT; 34 | } else { 35 | exponential_timeout = exponential_timeout * 2 < DEFAULT_TIMEOUT ? exponential_timeout * 2 : DEFAULT_TIMEOUT; 36 | return exponential_timeout; 37 | } 38 | } 39 | 40 | static void timeout_handler() { 41 | timeout_timer = NULL; 42 | 43 | if (update_in_progress) { 44 | update_in_progress = false; 45 | 46 | if (!connection_service_peek_pebble_app_connection()) { 47 | request_state_callback(REQUEST_STATE_NO_BLUETOOTH, 0); 48 | schedule_update(NO_BLUETOOTH_RETRY_DELAY); 49 | } else if (phone_contact) { 50 | request_state_callback(REQUEST_STATE_TIMED_OUT, 0); 51 | schedule_update(TIMEOUT_RETRY_DELAY); 52 | } else { 53 | request_state_callback(REQUEST_STATE_TIMED_OUT, 0); 54 | // If we haven't heard from the phone yet, retry immediately after timing out. 55 | schedule_update(0); 56 | } 57 | } 58 | } 59 | 60 | static void schedule_update(uint32_t delay) { 61 | if (update_in_progress) { 62 | return; 63 | } 64 | clear_timer(&request_timer); 65 | clear_timer(&timeout_timer); 66 | request_timer = app_timer_register(delay, request_update, NULL); 67 | } 68 | 69 | static void request_update() { 70 | request_timer = NULL; 71 | 72 | if (!connection_service_peek_pebble_app_connection()) { 73 | request_state_callback(REQUEST_STATE_NO_BLUETOOTH, 0); 74 | schedule_update(NO_BLUETOOTH_RETRY_DELAY); 75 | return; 76 | } 77 | 78 | DictionaryIterator *send_message; 79 | 80 | AppMessageResult begin_result = app_message_outbox_begin(&send_message); 81 | if (begin_result != APP_MSG_OK) { 82 | request_state_callback(REQUEST_STATE_BEGIN_FAILED, begin_result); 83 | schedule_update(SEND_FAILED_DELAY); 84 | return; 85 | } 86 | 87 | AppMessageResult send_result = app_message_outbox_send(); 88 | if (send_result != APP_MSG_OK) { 89 | request_state_callback(REQUEST_STATE_SEND_FAILED, send_result); 90 | schedule_update(SEND_FAILED_DELAY); 91 | return; 92 | } 93 | 94 | update_in_progress = true; 95 | timeout_timer = app_timer_register(timeout_length(), timeout_handler, NULL); 96 | request_state_callback(REQUEST_STATE_WAITING, 0); 97 | } 98 | 99 | static void in_received_handler(DictionaryIterator *received, void *context) { 100 | phone_contact = true; 101 | update_in_progress = false; 102 | 103 | int32_t msg_type; 104 | if (!get_int32(received, &msg_type, MESSAGE_KEY_msgType, true, 0)) { 105 | request_state_callback(REQUEST_STATE_BAD_APP_MESSAGE, 0); 106 | schedule_update(BAD_APP_MESSAGE_RETRY_DELAY); 107 | return; 108 | } 109 | 110 | if (msg_type == MSG_TYPE_DATA) { 111 | static DataMessage d; 112 | if (validate_data_message(received, &d)) { 113 | memcpy(_last_data_message, &d, sizeof(DataMessage)); 114 | save_last_data_message(_last_data_message); 115 | 116 | int32_t delay_seconds; 117 | if (get_prefs()->update_every_minute) { 118 | delay_seconds = 60; 119 | } else { 120 | int32_t next_update; 121 | if (_last_data_message->recency < SGV_UPDATE_FREQUENCY_SECONDS) { 122 | next_update = SGV_UPDATE_FREQUENCY_SECONDS - _last_data_message->recency; 123 | delay_seconds = next_update < 15 ? 15 : next_update; 124 | } else if (_last_data_message->recency < SGV_UPDATE_FREQUENCY_SECONDS + LATE_DATA_RETRY_PERIOD_SECONDS) { 125 | delay_seconds = 15; 126 | } else { 127 | delay_seconds = LATE_DATA_UPDATE_FREQUENCY_SECONDS; 128 | } 129 | } 130 | schedule_update((uint32_t) delay_seconds * 1000); 131 | 132 | request_state_callback(REQUEST_STATE_SUCCESS, 0); 133 | data_callback(_last_data_message); 134 | } else { 135 | request_state_callback(REQUEST_STATE_BAD_APP_MESSAGE, 0); 136 | schedule_update(BAD_APP_MESSAGE_RETRY_DELAY); 137 | } 138 | } else if (msg_type == MSG_TYPE_PREFERENCES) { 139 | prefs_callback(received); 140 | } else { 141 | request_state_callback(REQUEST_STATE_FETCH_ERROR, 0); 142 | schedule_update(ERROR_RETRY_DELAY); 143 | } 144 | } 145 | 146 | static void in_dropped_handler(AppMessageResult reason, void *context) { 147 | // https://developer.getpebble.com/docs/c/Foundation/AppMessage/#AppMessageResult 148 | request_state_callback(REQUEST_STATE_IN_DROPPED, reason); 149 | update_in_progress = false; 150 | schedule_update(IN_RETRY_DELAY); 151 | } 152 | 153 | static void out_failed_handler(DictionaryIterator *failed, AppMessageResult reason, void *context) { 154 | // https://developer.getpebble.com/docs/c/Foundation/AppMessage/#AppMessageResult 155 | request_state_callback(REQUEST_STATE_OUT_FAILED, reason); 156 | update_in_progress = false; 157 | schedule_update(OUT_RETRY_DELAY); 158 | } 159 | 160 | static void bluetooth_connection_handler(bool connected) { 161 | // Request data as soon as Bluetooth reconnects 162 | if (connected) { 163 | schedule_update(0); 164 | } 165 | } 166 | 167 | static void init_app_message() { 168 | app_message_register_inbox_received(in_received_handler); 169 | app_message_register_inbox_dropped(in_dropped_handler); 170 | app_message_register_outbox_failed(out_failed_handler); 171 | AppMessageResult open_result = app_message_open(CONTENT_SIZE, 8); 172 | if (open_result != APP_MSG_OK) { 173 | request_state_callback(REQUEST_STATE_OPEN_FAILED, open_result); 174 | } 175 | } 176 | 177 | void init_comm( 178 | void (*callback_for_data)(DataMessage *data), 179 | void (*callback_for_prefs)(DictionaryIterator *received), 180 | void (*callback_for_request_state)(RequestState state, AppMessageResult reason) 181 | ) { 182 | data_callback = callback_for_data; 183 | prefs_callback = callback_for_prefs; 184 | request_state_callback = callback_for_request_state; 185 | 186 | if (connection_service_peek_pebble_app_connection()) { 187 | // We expect the JS to initiate sending data first. 188 | update_in_progress = true; 189 | request_state_callback(REQUEST_STATE_WAITING, 0); 190 | timeout_timer = app_timer_register(timeout_length(), timeout_handler, NULL); 191 | } else { 192 | update_in_progress = false; 193 | request_state_callback(REQUEST_STATE_NO_BLUETOOTH, 0); 194 | schedule_update(NO_BLUETOOTH_RETRY_DELAY); 195 | } 196 | 197 | _last_data_message = malloc(sizeof(DataMessage)); 198 | init_app_message(); 199 | 200 | connection_service_subscribe((ConnectionHandlers) { 201 | .pebble_app_connection_handler = bluetooth_connection_handler 202 | }); 203 | } 204 | 205 | void deinit_comm() { 206 | app_message_deregister_callbacks(); 207 | free(_last_data_message); 208 | } 209 | 210 | bool comm_is_update_in_progress() { 211 | return update_in_progress; 212 | } 213 | -------------------------------------------------------------------------------- /src/js/vendor/lie.polyfill.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ES6 Promise polyfill 3 | * https://github.com/calvinmetcalf/lie @ 3.0.2 4 | * 5 | * Using something like Browserify would be more maintainable, but requiring 6 | * Node to build the app would also increase the complexity of contributing, 7 | * so punting for now. 8 | */ 9 | 10 | /* jshint ignore:start */ 11 | 12 | (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o element; its readystatechange event will be fired asynchronously once it is inserted 295 | // into the document. Do so, thus queuing up the task. Remember to clean up once it's been called. 296 | var scriptEl = global.document.createElement('script'); 297 | scriptEl.onreadystatechange = function () { 298 | nextTick(); 299 | 300 | scriptEl.onreadystatechange = null; 301 | scriptEl.parentNode.removeChild(scriptEl); 302 | scriptEl = null; 303 | }; 304 | global.document.documentElement.appendChild(scriptEl); 305 | }; 306 | } else { 307 | scheduleDrain = function () { 308 | setTimeout(nextTick, 0); 309 | }; 310 | } 311 | } 312 | 313 | var draining; 314 | var queue = []; 315 | //named nextTick for less confusing stack traces 316 | function nextTick() { 317 | draining = true; 318 | var i, oldQueue; 319 | var len = queue.length; 320 | while (len) { 321 | oldQueue = queue; 322 | queue = []; 323 | i = -1; 324 | while (++i < len) { 325 | oldQueue[i](); 326 | } 327 | len = queue.length; 328 | } 329 | draining = false; 330 | } 331 | 332 | module.exports = immediate; 333 | function immediate(task) { 334 | if (queue.push(task) === 1 && !draining) { 335 | scheduleDrain(); 336 | } 337 | } 338 | 339 | }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) 340 | },{}],3:[function(_dereq_,module,exports){ 341 | (function (global){ 342 | 'use strict'; 343 | if (typeof global.Promise !== 'function') { 344 | global.Promise = _dereq_('./lib'); 345 | } 346 | 347 | }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) 348 | },{"./lib":1}]},{},[3]); 349 | 350 | -------------------------------------------------------------------------------- /test/util.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import subprocess 4 | import time 5 | import urllib2 6 | from datetime import datetime 7 | 8 | import requests 9 | from dateutil import parser 10 | from dateutil.tz import tzlocal 11 | from libpebble2.communication.transports.websocket import MessageTargetPhone 12 | from libpebble2.communication.transports.websocket.protocol import AppConfigResponse 13 | from libpebble2.communication.transports.websocket.protocol import AppConfigSetup 14 | from libpebble2.communication.transports.websocket.protocol import WebSocketPhonesimAppConfig 15 | from pebble_tool.sdk.emulator import ManagedEmulatorTransport 16 | 17 | PLATFORMS = ('aplite', 'basalt') 18 | PORT = os.environ.get('MOCK_SERVER_PORT', 5555) 19 | MOCK_HOST = 'http://localhost:{}'.format(PORT) 20 | 21 | CONSTANTS = json.loads( 22 | open(os.path.join(os.path.dirname(__file__), '../src/js/constants.json')).read() 23 | ) 24 | BASE_CONFIG = CONSTANTS['DEFAULT_CONFIG'] 25 | 26 | def post_mock_server(url, data): 27 | requests.post(MOCK_HOST + url, data=json.dumps(data)) 28 | 29 | def pebble_install_and_run(platforms): 30 | _call('pebble kill') 31 | _call('pebble clean') 32 | # TODO ensure this is called from the main project directory 33 | _call('pebble build') 34 | for platform in platforms: 35 | _call('pebble install --emulator {}'.format(platform)) 36 | # Give the watchface time to show up 37 | time.sleep(10) 38 | 39 | def pebble_reinstall(platforms): 40 | _call('pebble kill') 41 | _call('pebble wipe') 42 | for platform in platforms: 43 | _call('pebble install --emulator {}'.format(platform)) 44 | time.sleep(10) 45 | 46 | def set_config(config, platforms): 47 | for platform in platforms: 48 | emu = ManagedEmulatorTransport(platform) 49 | emu.connect() 50 | time.sleep(0.5) 51 | emu.send_packet(WebSocketPhonesimAppConfig( 52 | config=AppConfigSetup()), 53 | target=MessageTargetPhone() 54 | ) 55 | time.sleep(0.5) 56 | emu.send_packet(WebSocketPhonesimAppConfig( 57 | config=AppConfigResponse(data=urllib2.quote(json.dumps(config)))), 58 | target=MessageTargetPhone() 59 | ) 60 | # Wait for the watchface to re-render and request data 61 | time.sleep(0.5) 62 | 63 | def pebble_screenshot(filename, platform): 64 | _call('pebble screenshot --emulator {} --no-open {}'.format(platform, filename)) 65 | 66 | def _call(command_str, **kwargs): 67 | print command_str 68 | return subprocess.Popen( 69 | command_str.split(' '), 70 | stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 71 | **kwargs 72 | ).communicate() 73 | 74 | def ensure_empty_dir(dirname): 75 | if os.path.isdir(dirname): 76 | _, err = _call('rm -r {}'.format(dirname)) 77 | if err != '': 78 | raise Exception(err) 79 | os.mkdir(dirname) 80 | 81 | def image_diff(test_file, gold_file, out_file): 82 | # requires ImageMagick 83 | _, diff = _call('compare -metric AE {} {} {}'.format(test_file, gold_file, out_file)) 84 | return diff == '0' 85 | 86 | 87 | class ScreenshotTest(object): 88 | @staticmethod 89 | def out_dir(): 90 | return os.path.join(os.path.dirname(__file__), 'output') 91 | 92 | @classmethod 93 | def summary_filename(cls): 94 | return os.path.join(cls.out_dir(), 'screenshots.html') 95 | 96 | def circleci_url(self): 97 | if os.environ.get('CIRCLECI'): 98 | return 'https://circleci.com/api/v1/project/{}/{}/{}/artifacts/{}/$CIRCLE_ARTIFACTS/{}'.format( 99 | os.environ['CIRCLE_PROJECT_USERNAME'], 100 | os.environ['CIRCLE_PROJECT_REPONAME'], 101 | os.environ['CIRCLE_BUILD_NUM'], 102 | os.environ['CIRCLE_NODE_INDEX'], 103 | os.path.relpath(self.summary_filename(), os.path.dirname(__file__)), 104 | ) 105 | else: 106 | return None 107 | 108 | @classmethod 109 | def test_filename(cls, platform): 110 | return os.path.join(cls.out_dir(), 'img', '{}-{}.png'.format(cls.__name__, platform)) 111 | 112 | @classmethod 113 | def gold_filename(cls, platform): 114 | return os.path.join(os.path.dirname(__file__), 'gold', '{}-{}.png'.format(cls.__name__, platform)) 115 | 116 | @classmethod 117 | def diff_filename(cls, platform): 118 | return os.path.join(cls.out_dir(), 'diff', '{}-{}.png'.format(cls.__name__, platform)) 119 | 120 | 121 | @classmethod 122 | def ensure_environment(cls): 123 | if not hasattr(ScreenshotTest, '_loaded_environment'): 124 | ScreenshotTest.test_count = 0 125 | pebble_install_and_run(PLATFORMS) 126 | ensure_empty_dir(cls.out_dir()) 127 | os.mkdir(os.path.join(cls.out_dir(), 'img')) 128 | os.mkdir(os.path.join(cls.out_dir(), 'diff')) 129 | ScreenshotTest.summary_file = SummaryFile(cls.summary_filename(), BASE_CONFIG) 130 | ScreenshotTest._loaded_environment = True 131 | else: 132 | ScreenshotTest.test_count += 1 133 | # The Pebble emulator gets flaky after a while 134 | if ScreenshotTest.test_count % 10 == 0: 135 | pebble_reinstall(PLATFORMS) 136 | 137 | def sgvs(self): 138 | raise NotImplementedError 139 | 140 | def treatments(self): 141 | return [] 142 | 143 | def profile(self): 144 | return [] 145 | 146 | def devicestatus(self): 147 | return [] 148 | 149 | def test_screenshot(self): 150 | if not hasattr(self, 'config'): 151 | self.config = {} 152 | 153 | # Create the SGVs at test run time, not at test definition time. 154 | # Otherwise, recency display in the screenshots can differ across runs. 155 | if not hasattr(self.sgvs, '__call__'): 156 | raise "sgvs attribute of test instance must be callable" 157 | 158 | self.ensure_environment() 159 | 160 | # XXX this should all be run in a single process, e.g. with Tornado 161 | self.test_sgvs = self.sgvs() 162 | post_mock_server('/set-sgv', self.test_sgvs) 163 | 164 | self.test_treatments = self.treatments() 165 | post_mock_server('/set-treatments', self.test_treatments) 166 | 167 | self.test_profile = self.profile() 168 | post_mock_server('/set-profile', self.test_profile) 169 | 170 | self.test_devicestatus = self.devicestatus() 171 | post_mock_server('/set-devicestatus', self.test_devicestatus) 172 | 173 | set_config(dict(BASE_CONFIG, nightscout_url=MOCK_HOST, __CLEAR_CACHE__=True, **self.config), PLATFORMS) 174 | 175 | fails = [] 176 | for platform in PLATFORMS: 177 | pebble_screenshot(self.test_filename(platform), platform) 178 | 179 | try: 180 | os.stat(self.gold_filename(platform)) 181 | except OSError: 182 | images_match = False 183 | reason = 'Test is missing "gold" image: {}'.format(self.gold_filename(platform)) 184 | else: 185 | images_match = image_diff(self.test_filename(platform), self.gold_filename(platform), self.diff_filename(platform)) 186 | reason = 'Screenshot does not match expected: "{}"'.format(self.__class__.__doc__) 187 | reason += '\n' + self.circleci_url() if self.circleci_url() else '' 188 | 189 | ScreenshotTest.summary_file.add_test_result(self, platform, images_match) 190 | if not images_match: 191 | fails.append((platform, reason)) 192 | 193 | assert fails == [], '\n'.join(['{}: {}'.format(p, reason) for p, reason in fails]) 194 | 195 | 196 | class SummaryFile(object): 197 | """Generate summary file with screenshots, in a very janky way for now.""" 198 | def __init__(self, out_file, base_config): 199 | self.out_file = out_file 200 | self.base_config = base_config 201 | self.fails = '' 202 | self.passes = '' 203 | 204 | def write(self): 205 | with open(self.out_file, 'w') as f: 206 | f.write(""" 207 | 208 | 215 | 216 | 217 | """ 218 | + 219 | """ 220 | 221 | {fails} 222 |
223 | 224 | {passes} 225 |
226 | """.format(fails=self.fails, passes=self.passes)) 227 | 228 | f.write(""" 229 | Default config (each test's config is merged into this): 230 |
231 | {} 232 | """.format(json.dumps(self.base_config))) 233 | 234 | def add_test_result(self, test_instance, platform, passed): 235 | details = """ 236 | {classname} [{platform}] {doc} 237 | {config} 238 | {sgvs} 239 | """.format( 240 | classname=test_instance.__class__.__name__, 241 | platform=platform, 242 | doc=test_instance.__class__.__doc__ or '', 243 | config=json.dumps(test_instance.config), 244 | sgvs=json.dumps(self.formatted_sgvs(test_instance.test_sgvs)) 245 | ) 246 | if test_instance.test_treatments: 247 | details += "{treatments}".format(treatments=self.format_created_at(test_instance.test_treatments)) 248 | if test_instance.test_profile: 249 | details += "{profile}".format(profile=test_instance.test_profile) 250 | if test_instance.test_devicestatus: 251 | details += "{devicestatus}".format(devicestatus=self.format_created_at(test_instance.test_devicestatus)) 252 | result = """ 253 | 254 | 255 | 256 | {details} 257 | 258 | """.format( 259 | test_filename=self.relative_path(test_instance.test_filename(platform)), 260 | klass=('pass' if passed else 'fail'), 261 | diff_filename=self.relative_path(test_instance.diff_filename(platform)), 262 | details=details, 263 | ) 264 | if passed: 265 | self.passes += result 266 | else: 267 | self.fails += result 268 | self.write() 269 | 270 | def relative_path(self, filename): 271 | return os.path.relpath(filename, os.path.dirname(self.out_file)) 272 | 273 | def formatted_sgvs(self, sgvs): 274 | return [ 275 | s 276 | if i == 0 277 | else { 278 | 'sgv': s.get('sgv'), 279 | 'ago': self.format_ago(datetime.fromtimestamp(s['date'] / 1000).replace(tzinfo=tzlocal())), 280 | } 281 | for i, s in enumerate(sgvs) 282 | ] 283 | 284 | def format_created_at(self, entries): 285 | out = [] 286 | for e in [_e.copy() for _e in entries]: 287 | e['ago'] = self.format_ago(parser.parse(e['created_at'])) 288 | del e['created_at'] 289 | out.append(e) 290 | return out 291 | 292 | @staticmethod 293 | def format_ago(time): 294 | minutes = int((datetime.now().replace(tzinfo=tzlocal()) - time).total_seconds() / 60) 295 | if minutes < 60: 296 | return '{}m'.format(minutes) 297 | else: 298 | return '{}h{}m'.format(minutes / 60, minutes % 60) 299 | --------------------------------------------------------------------------------