package org.fluxtream.connectors.withings; import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import com.google.gdata.util.RateLimitExceededException; import net.sf.json.JSONArray; import net.sf.json.JSONObject; import org.apache.commons.lang.StringUtils; import org.fluxtream.core.connectors.Connector; import org.fluxtream.core.connectors.ObjectType; import org.fluxtream.core.connectors.annotations.Updater; import org.fluxtream.core.connectors.updaters.AbstractUpdater; import org.fluxtream.core.connectors.updaters.AuthExpiredException; import org.fluxtream.core.connectors.updaters.RateLimitReachedException; import org.fluxtream.core.connectors.updaters.UpdateFailedException; import org.fluxtream.core.connectors.updaters.UpdateInfo; import org.fluxtream.core.domain.AbstractFacet; import org.fluxtream.core.domain.ApiKey; import org.fluxtream.core.domain.Notification; import org.fluxtream.core.services.ApiDataService; import org.fluxtream.core.services.JPADaoService; import org.fluxtream.core.utils.JPAUtils; import org.fluxtream.core.utils.TimeUtils; import org.fluxtream.core.utils.UnexpectedHttpResponseCodeException; import org.fluxtream.core.utils.Utils; import org.joda.time.DateTime; import org.joda.time.DateTimeConstants; import org.scribe.builder.ServiceBuilder; import org.scribe.builder.api.WithingsApi; import org.scribe.model.OAuthRequest; import org.scribe.model.Response; import org.scribe.model.SignatureType; import org.scribe.model.Token; import org.scribe.model.Verb; import org.scribe.oauth.OAuthService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component @Updater(prettyName = "Withings", value = 4, objectTypes = { WithingsBPMMeasureFacet.class, WithingsBodyScaleMeasureFacet.class, WithingsHeartPulseMeasureFacet.class, WithingsActivityFacet.class}, defaultChannels = {"Withings.weight","Withings.systolic", "Withings.diastolic"}) public class WithingsUpdater extends AbstractUpdater { private static final String LAST_ACTIVITY_SYNC_DATE = "lastActivitySyncDate"; private static final int WEIGHT = 1; private static final int HEIGHT = 4; private static final int FAT_FREE_MASS = 5; private static final int FAT_RATIO = 6; private static final int FAT_MASS_WEIGHT = 8; private static final int DIASTOLIC_BLOOD_PRESSURE = 9; private static final int SYSTOLIC_BLOOD_PRESSURE = 10; private static final int HEART_PULSE = 11; private final String WITHINGS_PULSE_LAUNCH_DATE = "2013-06-01"; private enum ApiVersion { V1, V2 } @Autowired JPADaoService jpaDaoService; public WithingsUpdater() { super(); } @Override protected void updateConnectorDataHistory(UpdateInfo updateInfo) throws Exception { // get user info and find out first seen date if (guestService.getApiKeyAttribute(updateInfo.apiKey, WithingsOAuthConnectorController.HAS_UPGRADED_TO_OAUTH)==null) { notificationsService.addNamedNotification(updateInfo.getGuestId(), Notification.Type.WARNING, connector().statusNotificationName(), "Heads Up. This server has recently been upgraded to a version that supports<br>" + "oauth with the Withings API. Please head to <a href=\"javascript:App.manageConnectors()\">Manage Connectors</a>,<br>" + "scroll to the Withings connector, and renew your tokens (look for the <i class=\"icon-resize-small icon-large\"></i> icon)"); // Record permanent failure since this connector won't work again until // it is reauthenticated guestService.setApiKeyStatus(updateInfo.apiKey.getId(), ApiKey.Status.STATUS_PERMANENT_FAILURE, null, ApiKey.PermanentFailReason.NEEDS_REAUTH); throw new UpdateFailedException("requires token reauthorization",true, ApiKey.PermanentFailReason.NEEDS_REAUTH); } final String userid = guestService.getApiKeyAttribute(updateInfo.apiKey, "userid"); // do v1 API call String url = "http://wbsapi.withings.net/measure"; Map<String,String> parameters = new HashMap<String,String>(); parameters.put("action", "getmeas"); parameters.put("userid", userid); parameters.put("startdate", "0"); parameters.put("enddate", String.valueOf(System.currentTimeMillis() / 1000)); fetchAndProcessJSON(updateInfo, url, parameters, ApiVersion.V1); // do v2 (activity) API call getActivityDataHistory(updateInfo, userid); } public void updateConnectorData(UpdateInfo updateInfo) throws Exception { long lastBodyscaleMeasurement = getLastBodyScaleMeasurement(updateInfo); long lastBloodPressureMeasurement = getLastBloodPressureMeasurement(updateInfo); long lastMeasurement = Math.max(lastBodyscaleMeasurement, lastBloodPressureMeasurement); final String userid = guestService.getApiKeyAttribute(updateInfo.apiKey, "userid"); final long startdate = lastMeasurement / 1000; final long enddate = System.currentTimeMillis() / 1000; if (guestService.getApiKeyAttribute(updateInfo.apiKey, WithingsOAuthConnectorController.HAS_UPGRADED_TO_OAUTH)==null) { notificationsService.addNamedNotification(updateInfo.getGuestId(), Notification.Type.WARNING, connector().statusNotificationName(), "Heads Up. This server has recently been upgraded to a version that supports<br>" + "oauth with the Withings API. Please head to <a href=\"javascript:App.manageConnectors()\">Manage Connectors</a>,<br>" + "scroll to the Withings connector, and renew your tokens (look for the <i class=\"icon-resize-small icon-large\"></i> icon)"); // Record permanent failure since this connector won't work again until // it is reauthenticated guestService.setApiKeyStatus(updateInfo.apiKey.getId(), ApiKey.Status.STATUS_PERMANENT_FAILURE, null, ApiKey.PermanentFailReason.NEEDS_REAUTH); throw new UpdateFailedException("requires token reauthorization",true, ApiKey.PermanentFailReason.NEEDS_REAUTH); } // do v1 API call String url = "http://wbsapi.withings.net/measure"; Map<String,String> parameters = new HashMap<String,String>(); parameters.put("action", "getmeas"); parameters.put("userid", userid); parameters.put("startdate", String.valueOf(startdate)); parameters.put("enddate", String.valueOf(enddate)); fetchAndProcessJSON(updateInfo, url, parameters, ApiVersion.V1); // do v2 (activity) API call final String lastActivitySyncDate = guestService.getApiKeyAttribute(updateInfo.apiKey, LAST_ACTIVITY_SYNC_DATE); if (lastActivitySyncDate ==null) getActivityDataHistory(updateInfo, userid); else getRecentActivityData(updateInfo, userid); } private void getActivityDataHistory(final UpdateInfo updateInfo, final String userid) throws Exception { final String todaysDate = TimeUtils.dateFormatterUTC.print(System.currentTimeMillis()); String urlv2 = "http://wbsapi.withings.net/v2/measure"; Map<String,String> parameters = new HashMap<String,String>(); parameters.put("action", "getactivity"); parameters.put("userid", userid); parameters.put("startdateymd", WITHINGS_PULSE_LAUNCH_DATE); parameters.put("enddateymd", String.valueOf(todaysDate)); fetchAndProcessJSON(updateInfo, urlv2, parameters, ApiVersion.V2); final String lastActivityDate = getLastActivityDate(updateInfo); guestService.setApiKeyAttribute(updateInfo.apiKey, LAST_ACTIVITY_SYNC_DATE, lastActivityDate); } private void getRecentActivityData(final UpdateInfo updateInfo, final String userid) throws Exception { final String todaysDate = TimeUtils.dateFormatterUTC.print(System.currentTimeMillis()); final String lastActivitySyncDate = guestService.getApiKeyAttribute(updateInfo.apiKey, LAST_ACTIVITY_SYNC_DATE); String urlv2 = String.format("http://wbsapi.withings.net/v2/measure", userid, lastActivitySyncDate, todaysDate); Map<String,String> parameters = new HashMap<String,String>(); parameters.put("action", "getactivity"); parameters.put("userid", userid); parameters.put("startdateymd", lastActivitySyncDate); parameters.put("enddateymd", String.valueOf(todaysDate)); fetchAndProcessJSON(updateInfo, urlv2, parameters, ApiVersion.V2); final String lastActivityDate = getLastActivityDate(updateInfo); guestService.setApiKeyAttribute(updateInfo.apiKey, LAST_ACTIVITY_SYNC_DATE, lastActivityDate); } private String getLastActivityDate(final UpdateInfo updateInfo) { final String entityName = JPAUtils.getEntityName(WithingsActivityFacet.class); final List<WithingsActivityFacet> facets = jpaDaoService.executeQueryWithLimit("SELECT facet from " + entityName + " facet WHERE facet.apiKeyId=? ORDER BY facet.date DESC", 1, WithingsActivityFacet.class, updateInfo.apiKey.getId()); if (facets.size()==0) return WITHINGS_PULSE_LAUNCH_DATE; return facets.get(0).date; } private long getLastBloodPressureMeasurement(final UpdateInfo updateInfo) { final String entityName = JPAUtils.getEntityName(WithingsBPMMeasureFacet.class); final List<WithingsBPMMeasureFacet> facets = jpaDaoService.executeQueryWithLimit("SELECT facet from " + entityName + " facet WHERE facet.apiKeyId=? ORDER BY facet.start DESC", 1, WithingsBPMMeasureFacet.class, updateInfo.apiKey.getId()); if (facets.size()==0) return 0; return facets.get(0).start + 1000; } private long getLastBodyScaleMeasurement(final UpdateInfo updateInfo) { final String entityName = JPAUtils.getEntityName(WithingsBodyScaleMeasureFacet.class); final List<WithingsBodyScaleMeasureFacet> facets = jpaDaoService.executeQueryWithLimit("SELECT facet from " + entityName + " facet WHERE facet.apiKeyId=? ORDER BY facet.start DESC", 1, WithingsBodyScaleMeasureFacet.class, updateInfo.apiKey.getId()); if (facets.size()==0) return 0; return facets.get(0).start + 1000; } public OAuthService getOAuthService(final ApiKey apiKey) { return new ServiceBuilder() .provider(WithingsApi.class) .apiKey(guestService.getApiKeyAttribute(apiKey, "withingsConsumerKey")) .apiSecret(guestService.getApiKeyAttribute(apiKey, "withingsConsumerSecret")) .signatureType(SignatureType.QueryString) .callback(env.get("homeBaseUrl") + "withings/upgradeToken") .build(); } private void fetchAndProcessJSON(final UpdateInfo updateInfo, final String url, final Map<String,String> parameters, ApiVersion apiVersion) throws Exception { long then = System.currentTimeMillis(); int httpResponseCode = 0; try { OAuthRequest request = new OAuthRequest(Verb.GET, url); for (String parameterName : parameters.keySet()) { request.addQuerystringParameter(parameterName, parameters.get(parameterName)); } OAuthService service = getOAuthService(updateInfo.apiKey); final String accessToken = guestService.getApiKeyAttribute(updateInfo.apiKey, "accessToken"); final Token token = new Token(accessToken, guestService.getApiKeyAttribute(updateInfo.apiKey, "tokenSecret")); service.signRequest(token, request); Response response = request.send(); httpResponseCode = response.getCode(); if (httpResponseCode!=200) throw new UpdateFailedException("Unexpected response code: " + httpResponseCode); String json = response.getBody(); JSONObject jsonObject = JSONObject.fromObject(json); final int withingsStatusCode = jsonObject.getInt("status"); String message = null; if (withingsStatusCode !=0) { switch (withingsStatusCode) { case 247: message = "247 : The userid provided is absent, or incorrect"; throw new UpdateFailedException(message, true, ApiKey.PermanentFailReason.unknownReason(message)); case 250: // 250 : The provided userid and/or Oauth credentials do not match throw new AuthExpiredException(); case 286: message = "286 : No such subscription was found"; throw new UpdateFailedException(message, true, ApiKey.PermanentFailReason.unknownReason(message)); case 293: message = "293 : The callback URL is either absent or incorrect"; throw new UpdateFailedException(message, true, ApiKey.PermanentFailReason.unknownReason(message)); case 294: message = "294 : No such subscription could be deleted"; throw new UpdateFailedException(message, true, ApiKey.PermanentFailReason.unknownReason(message)); case 304: message = "304 : The comment is either absent or incorrect"; throw new UpdateFailedException(message, true, ApiKey.PermanentFailReason.unknownReason(message)); case 305: // 305: Too many notifications are already set throw new RateLimitExceededException(); case 342: message = "342 : The signature (using Oauth) is invalid"; throw new UpdateFailedException( message, true, ApiKey.PermanentFailReason.unknownReason(message)); case 343: message = "343 : Wrong Notification Callback Url don't exist"; throw new UpdateFailedException(message, true, ApiKey.PermanentFailReason.unknownReason(message)); case 601: // 601: Too Many Requests throw new RateLimitReachedException(); case 2554: message = "2554 : Wrong action or wrong webservice"; throw new UpdateFailedException(message, true, ApiKey.PermanentFailReason.unknownReason(message)); case 2555: message = "2555 : An unknown error occurred"; throw new UpdateFailedException(message, false, ApiKey.PermanentFailReason.unknownReason(message)); case 2556: message = "2556 : Service is not defined"; throw new UpdateFailedException(message, true, ApiKey.PermanentFailReason.unknownReason(message)); default: throw new UnexpectedHttpResponseCodeException(withingsStatusCode, "Unexpected status code: " + withingsStatusCode); } } countSuccessfulApiCall(updateInfo.apiKey, updateInfo.objectTypes, then, url); if (!StringUtils.isEmpty(json)) storeMeasurements(updateInfo, json, apiVersion); } catch (Exception e) { countFailedApiCall(updateInfo.apiKey, updateInfo.objectTypes, then, url, Utils.stackTrace(e), httpResponseCode, e.getMessage()); throw e; } } private void storeMeasurements(final UpdateInfo updateInfo, final String json, final ApiVersion apiVersion) throws Exception { JSONObject jsonObject = JSONObject.fromObject(json); Object bodyObject = jsonObject.get("body"); if (bodyObject==null) return; JSONObject body = (JSONObject) bodyObject; switch (apiVersion) { case V1: JSONArray measuregrps = body.getJSONArray("measuregrps"); for (int i=0; i<measuregrps.size(); i++) storeV1MeasureGroup(updateInfo, measuregrps.getJSONObject(i)); break; case V2: Object activitiesObject = body.get("activities"); if (activitiesObject instanceof JSONObject) storeActivityMeasurement(updateInfo, (JSONObject)activitiesObject); else if (activitiesObject instanceof JSONArray) { JSONArray measurements = (JSONArray) activitiesObject; for (int i=0; i<measurements.size(); i++) storeActivityMeasurement(updateInfo, measurements.getJSONObject(i)); } break; } } private void storeV1MeasureGroup(final UpdateInfo updateInfo, final JSONObject measuregrp) throws Exception { final long date = measuregrp.getLong("date")*1000; JSONArray measures = measuregrp.getJSONArray ("measures"); final Connector connector = Connector.getConnector("withings"); Iterator measuresIterator = measures.iterator(); final Map<Integer, Float> measuresMap = new HashMap<Integer, Float>(); while(measuresIterator.hasNext()) { JSONObject measure = (net.sf.json.JSONObject) measuresIterator.next(); double pow = Math.abs (measure.getInt("unit")); double measureValue = measure.getDouble("value"); double divisor = Math.pow (10, pow); float fValue = (float)(measureValue / divisor); measuresMap.put(measure.getInt("type"), fValue); } final ApiDataService.FacetQuery facetQuery = new ApiDataService.FacetQuery("e.apiKeyId=? AND e.start=?", updateInfo.apiKey.getId(), date); if (measuresMap.containsKey(WEIGHT)) { final ApiDataService.FacetModifier<WithingsBodyScaleMeasureFacet> facetModifier = new ApiDataService.FacetModifier<WithingsBodyScaleMeasureFacet>() { @Override public WithingsBodyScaleMeasureFacet createOrModify(WithingsBodyScaleMeasureFacet facet, final Long apiKeyId) { if (facet==null) facet = new WithingsBodyScaleMeasureFacet(updateInfo.apiKey.getId()); facet.objectType = ObjectType.getObjectType(connector, "weight").value(); facet.measureTime = date; facet.start = date; facet.end = date; facet.weight = measuresMap.get(WEIGHT); extractCommonFacetData(facet, updateInfo); if (measuresMap.get(HEIGHT)!=null) facet.height = measuresMap.get(HEIGHT); if (measuresMap.get(FAT_FREE_MASS)!=null) facet.fatFreeMass = measuresMap.get(FAT_FREE_MASS); if (measuresMap.get(FAT_MASS_WEIGHT)!=null) facet.fatMassWeight = measuresMap.get(FAT_MASS_WEIGHT); if (measuresMap.get(FAT_RATIO)!=null) facet.fatRatio = measuresMap.get(FAT_RATIO); return facet; } }; final AbstractFacet createdOrModifiedFacet = apiDataService.createOrReadModifyWrite(WithingsBodyScaleMeasureFacet.class, facetQuery, facetModifier, updateInfo.apiKey.getId()); bodyTrackStorageService.storeApiData(updateInfo.apiKey, Arrays.asList(createdOrModifiedFacet)); } if (measuresMap.containsKey(DIASTOLIC_BLOOD_PRESSURE) && measuresMap.containsKey(SYSTOLIC_BLOOD_PRESSURE) && measuresMap.get(DIASTOLIC_BLOOD_PRESSURE)>0f && measuresMap.get(SYSTOLIC_BLOOD_PRESSURE)>0f) { final ApiDataService.FacetModifier<WithingsBPMMeasureFacet> facetModifier = new ApiDataService.FacetModifier<WithingsBPMMeasureFacet>() { @Override public WithingsBPMMeasureFacet createOrModify(WithingsBPMMeasureFacet facet, final Long apiKeyId) { if (facet==null) facet = new WithingsBPMMeasureFacet(updateInfo.apiKey.getId()); extractCommonFacetData(facet, updateInfo); facet.objectType = ObjectType.getObjectType(connector, "blood_pressure").value(); facet.measureTime = date; facet.start = date; facet.end = date; facet.systolic = measuresMap.get(SYSTOLIC_BLOOD_PRESSURE); facet.diastolic = measuresMap.get(DIASTOLIC_BLOOD_PRESSURE); if (measuresMap.get(HEART_PULSE)!=null) facet.heartPulse = measuresMap.get(HEART_PULSE); return facet; } }; final AbstractFacet createdOrModifiedFacet = apiDataService.createOrReadModifyWrite(WithingsBPMMeasureFacet.class, facetQuery, facetModifier, updateInfo.apiKey.getId()); bodyTrackStorageService.storeApiData(updateInfo.apiKey, Arrays.asList(createdOrModifiedFacet)); } if (measuresMap.containsKey(HEART_PULSE)) { final ApiDataService.FacetModifier<WithingsHeartPulseMeasureFacet> facetModifier = new ApiDataService.FacetModifier<WithingsHeartPulseMeasureFacet>() { @Override public WithingsHeartPulseMeasureFacet createOrModify(WithingsHeartPulseMeasureFacet facet, final Long apiKeyId) { if (facet==null) facet = new WithingsHeartPulseMeasureFacet(updateInfo.apiKey.getId()); extractCommonFacetData(facet, updateInfo); facet.objectType = ObjectType.getObjectType(connector, "heart_pulse").value(); facet.start = date; facet.end = date; facet.heartPulse = measuresMap.get(HEART_PULSE); return facet; } }; final AbstractFacet createdOrModifiedFacet = apiDataService.createOrReadModifyWrite(WithingsHeartPulseMeasureFacet.class, facetQuery, facetModifier, updateInfo.apiKey.getId()); bodyTrackStorageService.storeApiData(updateInfo.apiKey, Arrays.asList(createdOrModifiedFacet)); } } private void storeActivityMeasurement(final UpdateInfo updateInfo, final JSONObject activityData) throws Exception { final String date = activityData.getString("date"); final ApiDataService.FacetQuery facetQuery = new ApiDataService.FacetQuery("e.apiKeyId=? AND e.date=?", updateInfo.apiKey.getId(), date); final ApiDataService.FacetModifier<WithingsActivityFacet> facetModifier = new ApiDataService.FacetModifier<WithingsActivityFacet>() { @Override public WithingsActivityFacet createOrModify(WithingsActivityFacet facet, final Long apiKeyId) { if (facet==null) facet = new WithingsActivityFacet(updateInfo.apiKey.getId()); extractCommonFacetData(facet, updateInfo); facet.date = date; final DateTime dateTime = TimeUtils.dateFormatterUTC.parseDateTime(facet.date); // returns the starting midnight for the date facet.start = dateTime.getMillis(); facet.end = dateTime.getMillis()+ DateTimeConstants.MILLIS_PER_DAY-1; facet.startTimeStorage = facet.date + "T00:00:00.000"; facet.endTimeStorage = facet.date + "T23:59:59.999"; if (activityData.has("timezone")) facet.timezone = activityData.getString("timezone"); if (activityData.has("steps")) facet.steps = activityData.getInt("steps"); if (activityData.has("distance")) facet.distance = (float) activityData.getDouble("distance"); if (activityData.has("calories")) facet.calories = (float) activityData.getDouble("calories"); if (activityData.has("elevation")) facet.elevation = (float) activityData.getDouble("elevation"); return facet; }; }; final AbstractFacet createdOrModifiedFacet = apiDataService.createOrReadModifyWrite(WithingsActivityFacet.class, facetQuery, facetModifier, updateInfo.apiKey.getId()); bodyTrackStorageService.storeApiData(updateInfo.apiKey, Arrays.asList(createdOrModifiedFacet)); } @Override public void setDefaultChannelStyles(ApiKey apiKey) {} }