package io.particle.android.sdk.cloud; import android.annotation.SuppressLint; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.MainThread; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; import org.greenrobot.eventbus.EventBus; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.Date; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import javax.annotation.ParametersAreNonnullByDefault; import io.particle.android.sdk.cloud.Responses.ReadDoubleVariableResponse; import io.particle.android.sdk.cloud.Responses.ReadIntVariableResponse; import io.particle.android.sdk.cloud.Responses.ReadObjectVariableResponse; import io.particle.android.sdk.cloud.Responses.ReadStringVariableResponse; import io.particle.android.sdk.cloud.Responses.ReadVariableResponse; import io.particle.android.sdk.cloud.exceptions.ParticleCloudException; import io.particle.android.sdk.cloud.models.DeviceStateChange; import io.particle.android.sdk.utils.ParticleInternalStringUtils; import io.particle.android.sdk.utils.Preconditions; import io.particle.android.sdk.utils.TLog; import okio.Okio; import retrofit.RetrofitError; import retrofit.client.Response; import retrofit.mime.TypedByteArray; import retrofit.mime.TypedFile; import static io.particle.android.sdk.utils.Py.list; // don't warn about public APIs not being referenced inside this module, or about // the _default locale_ in a bunch of backend code @SuppressLint("DefaultLocale") @SuppressWarnings({"UnusedDeclaration"}) @ParametersAreNonnullByDefault public class ParticleDevice implements Parcelable { public enum ParticleDeviceType { CORE, PHOTON, P1, RASPBERRY_PI, RED_BEAR_DUO, BLUZ, DIGISTUMP_OAK, ELECTRON, ARGON, BORON, XENON; // FIXME: ADD MESH TYPES BELOW public static ParticleDeviceType fromInt(int intValue) { switch (intValue) { case 0: return CORE; case 8: return P1; case 10: return ELECTRON; case 31: return RASPBERRY_PI; case 82: return DIGISTUMP_OAK; case 88: return RED_BEAR_DUO; case 103: return BLUZ; case 6: default: return PHOTON; } } } public enum ParticleDeviceState { CAME_ONLINE, FLASH_STARTED, FLASH_SUCCEEDED, FLASH_FAILED, APP_HASH_UPDATED, ENTERED_SAFE_MODE, SAFE_MODE_UPDATER, WENT_OFFLINE, UNKNOWN } public enum VariableType { INT, DOUBLE, STRING } public static class FunctionDoesNotExistException extends Exception { public FunctionDoesNotExistException(String functionName) { super("Function " + functionName + " does not exist on this device"); } } public static class VariableDoesNotExistException extends Exception { public VariableDoesNotExistException(String variableName) { super("Variable " + variableName + " does not exist on this device"); } } public enum KnownApp { TINKER("tinker"); private final String appName; KnownApp(String appName) { this.appName = appName; } public String getAppName() { return appName; } } private static final int MAX_PARTICLE_FUNCTION_ARG_LENGTH = 63; private static final TLog log = TLog.get(ParticleDevice.class); private final CopyOnWriteArrayList<Long> subscriptions = new CopyOnWriteArrayList<>(); private final ApiDefs.CloudApi mainApi; private final ParticleCloud cloud; volatile DeviceState deviceState; private volatile boolean isFlashing = false; ParticleDevice(ApiDefs.CloudApi mainApi, ParticleCloud cloud, DeviceState deviceState) { this.mainApi = mainApi; this.cloud = cloud; this.deviceState = deviceState; } /** * Device ID string */ public String getID() { return deviceState.deviceId; } /** * Device name. Device can be renamed in the cloud via #setName(String) */ public String getName() { return deviceState.name; } /** * Rename the device in the cloud. If renaming fails name will stay the same. */ public void setName(String newName) throws ParticleCloudException { cloud.rename(this.deviceState.deviceId, newName); } /** * Is device connected to the cloud? */ public boolean isConnected() { return deviceState.isConnected; } /** * Get an immutable set of all the function names exposed by device */ public Set<String> getFunctions() { // no need for a defensive copy, this is an immutable set return deviceState.functions; } /** * Get an immutable map of exposed variables on device with their respective types. */ public Map<String, VariableType> getVariables() { // no need for a defensive copy, this is an immutable set return deviceState.variables; } /** * Device firmware version string */ public String getVersion() { return deviceState.version; } public boolean requiresUpdate() { return deviceState.requiresUpdate; } public ParticleDeviceType getDeviceType() { return deviceState.deviceType; } public int getPlatformID() { return deviceState.platformId; } public int getProductID() { return deviceState.productId; } public boolean isCellular() { return deviceState.cellular; } public String getImei() { return deviceState.imei; } public String getIccid() { return deviceState.lastIccid; } public String getCurrentBuild() { return deviceState.currentBuild; } public String getDefaultBuild() { return deviceState.defaultBuild; } public String getIpAddress() { return deviceState.ipAddress; } public String getLastAppName() { return deviceState.lastAppName; } public String getStatus() { return deviceState.status; } public Date getLastHeard() { return deviceState.lastHeard; } @WorkerThread public float getCurrentDataUsage() throws ParticleCloudException { float maxUsage = 0; try { Response response = mainApi.getCurrentDataUsage(deviceState.lastIccid); JSONObject result = new JSONObject(new String(((TypedByteArray) response.getBody()).getBytes())); JSONArray usages = result.getJSONArray("usage_by_day"); for (int i = 0; i < usages.length(); i++) { JSONObject usageElement = usages.getJSONObject(i); if (usageElement.has("mbs_used_cumulative")) { double usage = usageElement.getDouble("mbs_used_cumulative"); if (usage > maxUsage) { maxUsage = (float) usage; } } } } catch (JSONException | RetrofitError e) { throw new ParticleCloudException(e); } return maxUsage; } /** * Return the value for <code>variableName</code> on this Particle device. * <p> * Unless you specifically require generic handling, it is recommended that you use the * <code>get(type)Variable</code> methods instead, e.g.: <code>getIntVariable()</code>. * These type-specific methods don't require extra casting or type checking on your part, and * they more clearly and succinctly express your intent. */ @WorkerThread public Object getVariable(String variableName) throws ParticleCloudException, IOException, VariableDoesNotExistException { VariableRequester<Object, ReadObjectVariableResponse> requester = new VariableRequester<Object, ReadObjectVariableResponse>(this) { @Override ReadObjectVariableResponse callApi(String variableName) { return mainApi.getVariable(deviceState.deviceId, variableName); } }; return requester.getVariable(variableName); } /** * Return the value for <code>variableName</code> as an int. * <p> * Where practical, this method is recommended over the generic {@link #getVariable(String)}. * See the javadoc on that method for details. */ @WorkerThread public int getIntVariable(String variableName) throws ParticleCloudException, IOException, VariableDoesNotExistException, ClassCastException { VariableRequester<Integer, ReadIntVariableResponse> requester = new VariableRequester<Integer, ReadIntVariableResponse>(this) { @Override ReadIntVariableResponse callApi(String variableName) { return mainApi.getIntVariable(deviceState.deviceId, variableName); } }; return requester.getVariable(variableName); } /** * Return the value for <code>variableName</code> as a String. * <p> * Where practical, this method is recommended over the generic {@link #getVariable(String)}. * See the javadoc on that method for details. */ @WorkerThread public String getStringVariable(String variableName) throws ParticleCloudException, IOException, VariableDoesNotExistException, ClassCastException { VariableRequester<String, ReadStringVariableResponse> requester = new VariableRequester<String, ReadStringVariableResponse>(this) { @Override ReadStringVariableResponse callApi(String variableName) { return mainApi.getStringVariable(deviceState.deviceId, variableName); } }; return requester.getVariable(variableName); } /** * Return the value for <code>variableName</code> as a double. * <p> * Where practical, this method is recommended over the generic {@link #getVariable(String)}. * See the javadoc on that method for details. */ @WorkerThread public double getDoubleVariable(String variableName) throws ParticleCloudException, IOException, VariableDoesNotExistException, ClassCastException { VariableRequester<Double, ReadDoubleVariableResponse> requester = new VariableRequester<Double, ReadDoubleVariableResponse>(this) { @Override ReadDoubleVariableResponse callApi(String variableName) { return mainApi.getDoubleVariable(deviceState.deviceId, variableName); } }; return requester.getVariable(variableName); } /** * Call a function on the device * * @param functionName Function name * @param args Array of arguments to pass to the function on the device. * Arguments must not be more than MAX_PARTICLE_FUNCTION_ARG_LENGTH chars * in length. If any arguments are longer, a runtime exception will be thrown. * @return result code: a value of 1 indicates success */ @WorkerThread public int callFunction(String functionName, @Nullable List<String> args) throws ParticleCloudException, IOException, FunctionDoesNotExistException { // TODO: check response of calling a non-existent function if (!deviceState.functions.contains(functionName)) { throw new FunctionDoesNotExistException(functionName); } // null is accepted here, but it won't be in the Retrofit API call later if (args == null) { args = list(); } String argsString = ParticleInternalStringUtils.join(args, ','); Preconditions.checkArgument(argsString.length() < MAX_PARTICLE_FUNCTION_ARG_LENGTH, String.format("Arguments '%s' exceed max args length of %d", argsString, MAX_PARTICLE_FUNCTION_ARG_LENGTH)); Responses.CallFunctionResponse response; try { response = mainApi.callFunction(deviceState.deviceId, functionName, new FunctionArgs(argsString)); } catch (RetrofitError e) { throw new ParticleCloudException(e); } if (!response.connected) { cloud.onDeviceNotConnected(deviceState); throw new IOException("Device is not connected."); } else { return response.returnValue; } } /** * Call a function on the device * * @param functionName Function name * @return value of the function */ @WorkerThread public int callFunction(String functionName) throws ParticleCloudException, IOException, FunctionDoesNotExistException { return callFunction(functionName, null); } /** * Subscribe to events from this device * * @param eventNamePrefix (optional, may be null) a filter to match against for events. If * null or an empty string, all device events will be received by the handler * trigger eventHandler * @param handler The handler for the events received for this subscription. * @return the subscription ID * (see {@link ParticleCloud#subscribeToAllEvents(String, ParticleEventHandler)} for more info */ public long subscribeToEvents(@Nullable String eventNamePrefix, ParticleEventHandler handler) throws IOException { return cloud.subscribeToDeviceEvents(eventNamePrefix, deviceState.deviceId, handler); } /** * Unsubscribe from events. * * @param eventListenerID The ID of the subscription to be cancelled. (returned from * {@link #subscribeToEvents(String, ParticleEventHandler)} */ public void unsubscribeFromEvents(long eventListenerID) throws ParticleCloudException { cloud.unsubscribeFromEventWithID(eventListenerID); } /** * Remove device from current logged in user account */ @WorkerThread public void unclaim() throws ParticleCloudException { try { cloud.unclaimDevice(deviceState.deviceId); } catch (RetrofitError e) { throw new ParticleCloudException(e); } } public boolean isRunningTinker() { List<String> lowercaseFunctions = list(); for (String func : deviceState.functions) { lowercaseFunctions.add(func.toLowerCase()); } List<String> tinkerFunctions = list("analogread", "analogwrite", "digitalread", "digitalwrite"); return (isConnected() && lowercaseFunctions.containsAll(tinkerFunctions)); } public boolean isFlashing() { return isFlashing; } @WorkerThread public void flashKnownApp(final KnownApp knownApp) throws ParticleCloudException { performFlashingChange(() -> mainApi.flashKnownApp(deviceState.deviceId, knownApp.appName)); } @WorkerThread public void flashBinaryFile(final File file) throws ParticleCloudException { performFlashingChange(() -> mainApi.flashFile(deviceState.deviceId, new TypedFile("application/octet-stream", file))); } @WorkerThread public void flashBinaryFile(InputStream stream) throws ParticleCloudException, IOException { final byte[] bytes = Okio.buffer(Okio.source(stream)).readByteArray(); performFlashingChange(() -> mainApi.flashFile(deviceState.deviceId, new TypedFakeFile(bytes))); } @WorkerThread public void flashCodeFile(final File file) throws ParticleCloudException { performFlashingChange(() -> mainApi.flashFile(deviceState.deviceId, new TypedFile("multipart/form-data", file))); } @WorkerThread public void flashCodeFile(InputStream stream) throws ParticleCloudException, IOException { final byte[] bytes = Okio.buffer(Okio.source(stream)).readByteArray(); performFlashingChange(() -> mainApi.flashFile(deviceState.deviceId, new TypedFakeFile(bytes, "multipart/form-data", "code.ino"))); } public ParticleCloud getCloud() { return cloud; } @WorkerThread public void refresh() throws ParticleCloudException { // just calling this get method will update everything as expected. cloud.getDevice(deviceState.deviceId); } private interface FlashingChange { void executeFlashingChange() throws RetrofitError; } // FIXME: ugh. these "cloud.notifyDeviceChanged();" calls are a hint that flashing maybe // should just live in a class of its own, or that it should just be a delegate on // ParticleCloud. Review this later. private void performFlashingChange(FlashingChange flashingChange) throws ParticleCloudException { try { flashingChange.executeFlashingChange(); //listens for flashing event, on success unsubscribe from listening. subscribeToSystemEvent("spark/flash/status", new SimpleParticleEventHandler() { @Override public void onEvent(String eventName, ParticleEvent particleEvent) { if ("success".equals(particleEvent.dataPayload)) { isFlashing = false; try { ParticleDevice.this.refresh(); cloud.unsubscribeFromEventWithHandler(this); } catch (ParticleCloudException e) { // not much else we can really do here... log.w("Unable to reset flashing state for %s" + deviceState.deviceId, e); } } else { isFlashing = true; } cloud.notifyDeviceChanged(); } }); } catch (RetrofitError | IOException e) { throw new ParticleCloudException(e); } } /** * Subscribes to system events of current device. Events emitted to EventBus listener. * * @throws ParticleCloudException Failure to subscribe to system events. * @see <a href="https://github.com/greenrobot/EventBus">EventBus</a> */ @MainThread public void subscribeToSystemEvents() throws ParticleCloudException { try { EventBus eventBus = EventBus.getDefault(); subscriptions.add(subscribeToSystemEvent("spark/status", (eventName, particleEvent) -> sendUpdateStatusChange(eventBus, particleEvent.dataPayload))); subscriptions.add(subscribeToSystemEvent("spark/flash/status", (eventName, particleEvent) -> sendUpdateFlashChange(eventBus, particleEvent.dataPayload))); subscriptions.add(subscribeToSystemEvent("spark/device/app-hash", (eventName, particleEvent) -> sendSystemEventBroadcast(new DeviceStateChange(ParticleDevice.this, ParticleDeviceState.APP_HASH_UPDATED), eventBus))); subscriptions.add(subscribeToSystemEvent("spark/status/safe-mode", (eventName, particleEvent) -> sendSystemEventBroadcast(new DeviceStateChange(ParticleDevice.this, ParticleDeviceState.SAFE_MODE_UPDATER), eventBus))); subscriptions.add(subscribeToSystemEvent("spark/safe-mode-updater/updating", (eventName, particleEvent) -> sendSystemEventBroadcast(new DeviceStateChange(ParticleDevice.this, ParticleDeviceState.ENTERED_SAFE_MODE), eventBus))); } catch (IOException e) { log.d("Failed to auto-subscribe to system events"); throw new ParticleCloudException(e); } } private void sendSystemEventBroadcast(DeviceStateChange deviceStateChange, EventBus eventBus) { cloud.sendSystemEventBroadcast(deviceStateChange); eventBus.post(deviceStateChange); } /** * Unsubscribes from system events of current device. * * @throws ParticleCloudException Failure to unsubscribe from system events. */ public void unsubscribeFromSystemEvents() throws ParticleCloudException { for (Long subscriptionId : subscriptions) { unsubscribeFromEvents(subscriptionId); } } private long subscribeToSystemEvent(String eventNamePrefix, SimpleParticleEventHandler particleEventHandler) throws IOException { //Error would be handled in same way for every event name prefix, thus only simple onEvent listener is needed return subscribeToEvents(eventNamePrefix, new ParticleEventHandler() { @Override public void onEvent(String eventName, ParticleEvent particleEvent) { particleEventHandler.onEvent(eventName, particleEvent); } @Override public void onEventError(Exception e) { log.d("Event error in system event handler"); } }); } private void sendUpdateStatusChange(EventBus eventBus, String data) { DeviceStateChange deviceStateChange = null; switch (data) { case "online": sendSystemEventBroadcast(new DeviceStateChange(this, ParticleDeviceState.CAME_ONLINE), eventBus); break; case "offline": sendSystemEventBroadcast(new DeviceStateChange(this, ParticleDeviceState.WENT_OFFLINE), eventBus); break; } } private void sendUpdateFlashChange(EventBus eventBus, String data) { DeviceStateChange deviceStateChange = null; switch (data) { case "started": sendSystemEventBroadcast(new DeviceStateChange(this, ParticleDeviceState.FLASH_STARTED), eventBus); break; case "success": sendSystemEventBroadcast(new DeviceStateChange(this, ParticleDeviceState.FLASH_SUCCEEDED), eventBus); break; } } @Override public String toString() { return "ParticleDevice{" + "deviceId=" + deviceState.deviceId + ", isConnected=" + deviceState.isConnected + ", deviceType=" + deviceState.deviceType + '}'; } //region Parcelable @Override public void writeToParcel(Parcel dest, int flags) { dest.writeParcelable(deviceState, flags); } @Override public int describeContents() { return 0; } public static final Creator<ParticleDevice> CREATOR = new Creator<ParticleDevice>() { @Override public ParticleDevice createFromParcel(Parcel in) { SDKProvider sdkProvider = ParticleCloudSDK.getSdkProvider(); DeviceState deviceState = in.readParcelable(DeviceState.class.getClassLoader()); return sdkProvider.getParticleCloud().getDeviceFromState(deviceState); } @Override public ParticleDevice[] newArray(int size) { return new ParticleDevice[size]; } }; //endregion private static class TypedFakeFile extends TypedByteArray { private final String fileName; /** * Constructs a new typed byte array. Sets mimeType to {@code application/unknown} if absent. * * @throws NullPointerException if bytes are null */ public TypedFakeFile(byte[] bytes) { this(bytes, "application/octet-stream", "tinker_firmware.bin"); } public TypedFakeFile(byte[] bytes, String mimeType, String fileName) { super(mimeType, bytes); this.fileName = fileName; } @Override public String fileName() { return fileName; } } private static abstract class VariableRequester<T, R extends ReadVariableResponse<T>> { @WorkerThread abstract R callApi(String variableName); private final ParticleDevice device; VariableRequester(ParticleDevice device) { this.device = device; } @WorkerThread T getVariable(String variableName) throws ParticleCloudException, IOException, VariableDoesNotExistException { if (!device.deviceState.variables.containsKey(variableName)) { throw new VariableDoesNotExistException(variableName); } R reply; try { reply = callApi(variableName); } catch (RetrofitError e) { throw new ParticleCloudException(e); } if (!reply.coreInfo.connected) { // FIXME: we should be doing this "connected" check on _any_ reply that comes back // with a "coreInfo" block. device.cloud.onDeviceNotConnected(device.deviceState); throw new IOException("Device is not connected."); } else { return reply.result; } } } }