io.klerch.alexa.state.handler.AWSIotStateHandler Maven / Gradle / Ivy
Show all versions of alexa-skills-kit-states-java Show documentation
/**
* Made by Kay Lerch (https://twitter.com/KayLerch)
*
* Attached license applies.
* This library is licensed under GNU GENERAL PUBLIC LICENSE Version 3 as of 29 June 2007
*/
package io.klerch.alexa.state.handler;
import com.amazon.speech.speechlet.Session;
import com.amazonaws.services.iot.AWSIot;
import com.amazonaws.services.iot.AWSIotClient;
import com.amazonaws.services.iot.model.*;
import com.amazonaws.services.iotdata.AWSIotData;
import com.amazonaws.services.iotdata.AWSIotDataClient;
import com.amazonaws.services.iotdata.model.GetThingShadowRequest;
import com.amazonaws.services.iotdata.model.GetThingShadowResult;
import com.amazonaws.services.iotdata.model.UpdateThingShadowRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.klerch.alexa.state.model.AlexaScope;
import io.klerch.alexa.state.model.AlexaStateModel;
import io.klerch.alexa.state.model.AlexaStateObject;
import io.klerch.alexa.state.utils.AlexaStateException;
import io.klerch.alexa.state.utils.EncryptUtils;
import org.apache.log4j.Logger;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import static java.lang.String.format;
/**
* As this handler works in the user and application scope it persists all models to a thing shadow in AWS IoT.
* A saved state goes into the "desired" JSON portion of a shadow state whereas state is read only from
* the "reported" portion of that shadow. That said this handler differs a bit from the other ones as you
* cannot expect to read state like you wrote it to the store. It needs the thing to fulfill the desired
* state and reports it back to the shadow. That's how you cannot only trigger physical action by saving state
* over this handler but also get informed about current thing state.
*/
public class AWSIotStateHandler extends AlexaSessionStateHandler {
private final Logger log = Logger.getLogger(AWSIotStateHandler.class);
private final AWSIot awsClient;
private final AWSIotData awsDataClient;
private static final String thingAttributeName = "name";
private static final String thingAttributeUser = "amzn-user-id";
private static final String thingAttributeApp = "amzn-app-id";
private List thingsExisting = new ArrayList<>();
public AWSIotStateHandler(final Session session) {
this(session, new AWSIotClient(), new AWSIotDataClient());
}
public AWSIotStateHandler(final Session session, final AWSIot awsClient, final AWSIotData awsDataClient) {
super(session);
this.awsClient = awsClient;
this.awsDataClient = awsDataClient;
}
/**
* Returns the AWS connection client used by this handler to manage resources
* in AWS IoT.
*
* @return AWS connection client for AWS IoT
*/
public AWSIot getAwsClient() {
return this.awsClient;
}
/**
* Returns the AWS connection client used by this handler to store model states in
* thing shadows of AWS IoT.
*
* @return AWS data connection client for AWS IoT
*/
AWSIotData getAwsDataClient() {
return this.awsDataClient;
}
/**
* {@inheritDoc}
*/
@Override
public void writeModels(final Collection models) throws AlexaStateException {
// write to session
super.writeModels(models);
for (final AlexaStateModel model : models) {
if (model.hasUserScopedField()) {
publishState(model, AlexaScope.USER);
}
if (model.hasApplicationScopedField()) {
publishState(model, AlexaScope.APPLICATION);
}
}
}
/**
* {@inheritDoc}
*/
@Override
public void writeValues(final Collection stateObjects) throws AlexaStateException {
// write to session
super.writeValues(stateObjects);
for (final AlexaStateObject stateObject : stateObjects) {
if (stateObject.getScope().isIn(AlexaScope.USER, AlexaScope.APPLICATION)) {
// only publish USER or APPLICATION scoped state objects
publishState(stateObject);
}
}
}
/**
* {@inheritDoc}
*/
@Override
public Optional readModel(final Class modelClass) throws AlexaStateException {
return this.readModel(modelClass, null);
}
/**
* {@inheritDoc}
*/
@Override
public void removeModels(final Collection models) throws AlexaStateException {
super.removeModels(models);
for (final AlexaStateModel model : models) {
if (model.hasSessionScopedField() || model.hasUserScopedField()) {
removeModelFromShadow(model, AlexaScope.USER);
}
if (model.hasApplicationScopedField()) {
removeModelFromShadow(model, AlexaScope.APPLICATION);
}
log.debug(format("Removed state from AWS IoT shadow for '%1$s'.", model));
}
}
/**
* {@inheritDoc}
*/
@Override
public void removeValues(final Collection ids) throws AlexaStateException {
super.removeValues(ids);
for (final String id : ids) {
removeNodeFromShadow(id, AlexaScope.USER);
removeNodeFromShadow(id, AlexaScope.APPLICATION);
log.debug(String.format("Removed value from AWS IoT shadow for '%1$s'.", id));
}
}
/**
* {@inheritDoc}
*/
@Override
public Optional readModel(final Class modelClass, final String id) throws AlexaStateException {
// if there is nothing for this model in the session ...
final Optional modelSession = super.readModel(modelClass, id);
// create new model with given id. for now we assume a model exists for this id. we find out by
// reading file from the bucket in the following lines. only if this is true model will be written back to session
final TModel model = modelSession.orElse(createModel(modelClass, id));
// we need to remember if there will be something from thing shadow to be written to the model
// in order to write those values back to the session at the end of this method
Boolean modelChanged = false;
// and if there are user-scoped fields ...
if (model.hasUserScopedField() && fromThingShadowToModel(model, AlexaScope.USER)) {
modelChanged = true;
}
// and if there are app-scoped fields ...
if (model.hasApplicationScopedField() && fromThingShadowToModel(model, AlexaScope.APPLICATION)) {
modelChanged = true;
}
// so if model changed from within something out of the shadow we want this to be in the speechlet as well
// this gives you access to user- and app-scoped attributes throughout a session without reading from S3 over and over again
if (modelChanged) {
super.writeModel(model);
return Optional.of(model);
} else {
// if there was nothing received from IOT and there is nothing to return from session
// then its not worth return the model. better indicate this model does not exist
return modelSession.isPresent() ? Optional.of(model) : Optional.empty();
}
}
/**
* {@inheritDoc}
*/
@Override
public Optional readValue(final String id, final AlexaScope scope) throws AlexaStateException {
if (AlexaScope.SESSION.includes(scope)) {
return super.readValue(id, scope);
}
return getNodeFromThingShadow(id, scope).map(value -> new AlexaStateObject(id, value, scope));
}
/**
* Returns name of the thing whose shadow is updated by this handler. It depends on
* the scope of the fields persisted in AWS IoT as APPLICATION-scoped fields go to a different
* thing shadow than USER-scoped fields.
*
* @param scope The scope this thing is dedicated to
* @return Name of the thing for this scope
* @throws AlexaStateException Any error regarding thing name generation
*/
public String getThingName(final AlexaScope scope) throws AlexaStateException {
return AlexaScope.APPLICATION.includes(scope) ? getAppScopedThingName() : getUserScopedThingName();
}
/**
* The thing will be created in AWS IoT if not existing for this application (when scope
* APPLICATION is given) or for this user in this application (when scope USER is given)
*
* @param scope The scope this thing is dedicated to
* @throws AlexaStateException Any error regarding thing creation or existence check
*/
public void createThingIfNotExisting(final AlexaScope scope) throws AlexaStateException {
final String thingName = getThingName(scope);
if (!doesThingExist(thingName)) {
createThing(thingName, scope);
}
}
/**
* Returns the name of the thing which is used to store model state scoped
* as USER
*
* @return Thing name for user-wide model state
* @throws AlexaStateException some exceptions may occure when encrypting the user-id
*/
String getUserScopedThingName() throws AlexaStateException {
// user-ids in Alexa are too long for thing names in AWS IOT.
// use the SHA1-hash of the user-id
final String userHash;
try {
userHash = EncryptUtils.encryptSha1(session.getUser().getUserId());
} catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
final String error = "Could not encrypt user-id for generating the IOT thing-name";
log.error(error, e);
throw AlexaStateException.create(error).withHandler(this).withCause(e).build();
}
return getAppScopedThingName() + "-" + userHash;
}
/**
* Returns the name of the thing which is used to store model state scoped
* as APPLICATION
*
* @return Thing name for application-wide model state
*/
String getAppScopedThingName() {
// thing names do not allow dots in it
return session.getApplication().getApplicationId().replace(".", "-");
}
/**
* Returns if the thing dedicated to the scope given is existing in AWS IoT.
*
* @param scope The scope this thing is dedicated to
* @return True, if the thing dedicated to the scope given is existing in AWS IoT.
* @throws AlexaStateException Any error regarding thing creation or existence check
*/
public boolean doesThingExist(final AlexaScope scope) throws AlexaStateException {
final String thingName = getThingName(scope);
return doesThingExist(thingName);
}
private void removeModelFromShadow(final AlexaStateModel model, final AlexaScope scope) throws AlexaStateException {
removeNodeFromShadow(model.getAttributeKey(), scope);
}
private void removeNodeFromShadow(final String nodeName, final AlexaScope scope) throws AlexaStateException {
final String thingName = getThingName(scope);
final String thingState = getState(scope);
try {
final ObjectMapper mapper = new ObjectMapper();
final JsonNode root = mapper.readTree(thingState);
if (!root.isMissingNode()) {
final JsonNode desired = root.path("state").path("desired");
if (!desired.isMissingNode() && desired instanceof ObjectNode) {
((ObjectNode) desired).remove(nodeName);
final String json = "{\"state\":{\"desired\":" + mapper.writeValueAsString(desired) + "}}";
publishState(thingName, json);
}
}
} catch (final IOException e) {
final String error = format("Could not extract model state of '%1$s' from thing shadow '%2$s'", nodeName, thingName);
log.error(error, e);
throw AlexaStateException.create(error).withCause(e).build();
}
}
private Optional getNodeFromThingShadow(final String nodeName, final AlexaScope scope) throws AlexaStateException {
// read from item with scoped model
final String thingName = getThingName(scope);
final String thingState = getState(scope);
try {
final ObjectMapper mapper = new ObjectMapper();
final JsonNode node = mapper.readTree(thingState).path("state").path("reported").path(nodeName);
return !node.isMissingNode() ? Optional.of(mapper.writeValueAsString(node)) : Optional.empty();
} catch (IOException e) {
final String error = format("Could not extract model state of '%1$s' from thing shadow '%2$s'", nodeName, thingName);
log.error(error, e);
throw AlexaStateException.create(error).withCause(e).build();
}
}
private boolean fromThingShadowToModel(final AlexaStateModel model, final AlexaScope scope) throws AlexaStateException {
final Optional state = getNodeFromThingShadow(model.getAttributeKey(), scope);
return state.isPresent() && model.fromJSON(state.get(), scope);
}
private String getState(final AlexaScope scope) throws AlexaStateException {
final String thingName = getThingName(scope);
createThingIfNotExisting(scope);
final GetThingShadowRequest awsRequest = new GetThingShadowRequest().withThingName(thingName);
try {
final GetThingShadowResult response = awsDataClient.getThingShadow(awsRequest);
final ByteBuffer buffer = response.getPayload();
try {
return (buffer != null && buffer.hasArray()) ? new String(buffer.array(), "UTF-8") : "{}";
} catch (UnsupportedEncodingException e) {
final String error = format("Could not handle received contents of thing-shadow '%1$s'", thingName);
log.error(error, e);
throw AlexaStateException.create(error).withCause(e).withHandler(this).build();
}
}
// if a thing does not have a shadow this is a usual exception
catch (com.amazonaws.services.iotdata.model.ResourceNotFoundException e) {
log.info(e);
// we are fine with a thing having no shadow what just means there's nothing to read out for the model
// return an empty JSON to indicate nothing is in the thing shadow
return "{}";
}
}
private void publishState(final AlexaStateModel model, final AlexaScope scope) throws AlexaStateException {
final String thingName = getThingName(scope);
createThingIfNotExisting(scope);
final String payload = "{\"state\":{\"desired\":{\"" + model.getAttributeKey() + "\":" + model.toJSON(scope) + "}}}";
publishState(thingName, payload);
log.debug(format("State '%1$s' is published to shadow of '%2$s' in AWS IoT.", payload, thingName));
}
private void publishState(final AlexaStateObject stateObject) throws AlexaStateException {
final String thingName = getThingName(stateObject.getScope());
createThingIfNotExisting(stateObject.getScope());
// wrap non-primitive values in quotes (json string-value)
final Object state = stateObject.getValue().getClass().isPrimitive() ? stateObject.getValue() : "\"" + stateObject.getValue() + "\"";
final String payload = "{\"state\":{\"desired\":{\"" + stateObject.getId() + "\":" + state + "}}}";
publishState(thingName, payload);
log.debug(format("State '%1$s' is published to shadow of '%2$s' in AWS IoT.", payload, thingName));
}
private void publishState(final String thingName, final String json) throws AlexaStateException {
final ByteBuffer buffer;
try {
buffer = ByteBuffer.wrap(json.getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
final String error = format("Could not prepare JSON for model state publication to thing shadow '%1$s'", thingName);
log.error(error, e);
throw AlexaStateException.create(error).withCause(e).withHandler(this).build();
}
final UpdateThingShadowRequest iotRequest = new UpdateThingShadowRequest().withThingName(thingName).withPayload(buffer);
awsDataClient.updateThingShadow(iotRequest);
}
private void createThing(final String thingName, final AlexaScope scope) {
// only create thing if not already existing
final AttributePayload attrPayload = new AttributePayload();
// add thing name as attribute as well. this is how the handler queries for the thing from now on
attrPayload.addAttributesEntry(thingAttributeName, thingName);
// if scope is user an attribute saves the plain user id as it is encrypted in the thing name
if (AlexaScope.USER.includes(scope)) {
attrPayload.addAttributesEntry(thingAttributeUser, session.getUser().getUserId());
}
// another thing attributes holds the Alexa application-id
attrPayload.addAttributesEntry(thingAttributeApp, session.getApplication().getApplicationId());
// now create the thing
final CreateThingRequest request = new CreateThingRequest().withThingName(thingName).withAttributePayload(attrPayload);
awsClient.createThing(request);
log.info(format("Thing '%1$s' is created in AWS IoT.", thingName));
}
private boolean doesThingExist(final String thingName) {
// if already checked existence than return immediately
if (thingsExisting.contains(thingName)) {
return true;
}
// query by an attribute having the name of the thing
// unfortunately you can only query for things with their attributes, not directly with their names
final ListThingsRequest request = new ListThingsRequest().withAttributeName(thingAttributeName).withAttributeValue(thingName).withMaxResults(1);
final ListThingsResult result = awsClient.listThings(request);
if (result != null && result.getThings() != null && result.getThings().isEmpty()) {
thingsExisting.add(thingName);
return true;
}
return false;
}
}