diff --git a/build.gradle b/build.gradle index c477816..5e05bce 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { } group 'com.valashko.xaapi' -version '0.1' +version '0.2' sourceCompatibility = 1.8 diff --git a/src/main/java/com/valashko/xaapi/command/WriteCommand.java b/src/main/java/com/valashko/xaapi/command/WriteCommand.java new file mode 100644 index 0000000..cc1e771 --- /dev/null +++ b/src/main/java/com/valashko/xaapi/command/WriteCommand.java @@ -0,0 +1,23 @@ +package com.valashko.xaapi.command; + +import com.google.gson.JsonObject; +import com.valashko.xaapi.device.SlaveDevice; + +import java.nio.charset.StandardCharsets; + +public class WriteCommand implements ICommand { + private SlaveDevice device; + private JsonObject data; + + public WriteCommand(SlaveDevice device, JsonObject data, String key) { + this.device = device; + this.data = data; + data.addProperty("key", key); + } + + @Override + public byte[] toBytes() { + String what = "{{\"cmd\":\"write\", \"sid\":\""+ device.getSid() +"\", \"data\":" + data + "}}"; + return what.getBytes(StandardCharsets.US_ASCII); + } +} diff --git a/src/main/java/com/valashko/xaapi/device/BuiltinDevice.java b/src/main/java/com/valashko/xaapi/device/BuiltinDevice.java index f1e33e5..a561a2f 100644 --- a/src/main/java/com/valashko/xaapi/device/BuiltinDevice.java +++ b/src/main/java/com/valashko/xaapi/device/BuiltinDevice.java @@ -2,7 +2,6 @@ import com.google.gson.JsonParser; import com.valashko.xaapi.XaapiException; -import sun.reflect.generics.reflectiveObjects.NotImplementedException; public abstract class BuiltinDevice { diff --git a/src/main/java/com/valashko/xaapi/device/SlaveDevice.java b/src/main/java/com/valashko/xaapi/device/SlaveDevice.java index a891495..e67c175 100644 --- a/src/main/java/com/valashko/xaapi/device/SlaveDevice.java +++ b/src/main/java/com/valashko/xaapi/device/SlaveDevice.java @@ -15,10 +15,12 @@ public enum Type { } protected static JsonParser JSON_PARSER = new JsonParser(); + protected XiaomiGateway gateway; private String sid; private Type type; - public SlaveDevice(String sid, Type type) { + public SlaveDevice(XiaomiGateway gateway, String sid, Type type) { + this.gateway = gateway; this.sid = sid; this.type = type; } diff --git a/src/main/java/com/valashko/xaapi/device/Utility.java b/src/main/java/com/valashko/xaapi/device/Utility.java new file mode 100644 index 0000000..0170508 --- /dev/null +++ b/src/main/java/com/valashko/xaapi/device/Utility.java @@ -0,0 +1,12 @@ +package com.valashko.xaapi.device; + +public class Utility { + + public static String toHexString(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/valashko/xaapi/device/XiaomiCube.java b/src/main/java/com/valashko/xaapi/device/XiaomiCube.java index ecebbe7..23daf90 100644 --- a/src/main/java/com/valashko/xaapi/device/XiaomiCube.java +++ b/src/main/java/com/valashko/xaapi/device/XiaomiCube.java @@ -29,8 +29,8 @@ public enum Action { private HashMap> actionsCallbacks = new HashMap<>(); private HashMap> rotationCallbacks = new HashMap<>(); - public XiaomiCube(String sid) { - super(sid, Type.XiaomiCube); + public XiaomiCube(XiaomiGateway gateway, String sid) { + super(gateway, sid, Type.XiaomiCube); } @Override diff --git a/src/main/java/com/valashko/xaapi/device/XiaomiDoorWindowSensor.java b/src/main/java/com/valashko/xaapi/device/XiaomiDoorWindowSensor.java index 7b0a087..6645667 100644 --- a/src/main/java/com/valashko/xaapi/device/XiaomiDoorWindowSensor.java +++ b/src/main/java/com/valashko/xaapi/device/XiaomiDoorWindowSensor.java @@ -13,8 +13,8 @@ public enum Status { private Status lastStatus; - public XiaomiDoorWindowSensor(String sid) { - super(sid, Type.XiaomiCube); + public XiaomiDoorWindowSensor(XiaomiGateway gateway, String sid) { + super(gateway, sid, Type.XiaomiCube); } @Override diff --git a/src/main/java/com/valashko/xaapi/device/XiaomiGateway.java b/src/main/java/com/valashko/xaapi/device/XiaomiGateway.java index d8f10ff..246189e 100644 --- a/src/main/java/com/valashko/xaapi/device/XiaomiGateway.java +++ b/src/main/java/com/valashko/xaapi/device/XiaomiGateway.java @@ -1,20 +1,27 @@ package com.valashko.xaapi.device; import com.google.gson.Gson; +import com.google.gson.JsonObject; import com.valashko.xaapi.XaapiException; -import com.valashko.xaapi.channel.DirectChannel; -import com.valashko.xaapi.channel.IncomingMulticastChannel; -import com.valashko.xaapi.command.WhoisCommand; -import com.valashko.xaapi.command.GetIdListCommand; -import com.valashko.xaapi.command.ReadCommand; -import com.valashko.xaapi.reply.GetIdListReply; -import com.valashko.xaapi.reply.ReadReply; -import com.valashko.xaapi.reply.WhoisReply; +import com.valashko.xaapi.channel.*; +import com.valashko.xaapi.command.*; +import com.valashko.xaapi.reply.*; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; import java.io.IOException; import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.concurrent.Executor; public class XiaomiGateway { @@ -22,10 +29,15 @@ public class XiaomiGateway { private static final String GROUP = "224.0.0.50"; private static final int PORT = 9898; private static final int PORT_DISCOVERY = 4321; + private static final byte[] IV = + { 0x17, (byte)0x99, 0x6d, 0x09, 0x3d, 0x28, (byte)0xdd, (byte)0xb3, + (byte)0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58, 0x56, 0x2e}; private static final Gson GSON = new Gson(); private String sid; + private Optional key = Optional.empty(); + private Cipher cipher; private IncomingMulticastChannel incomingMulticastChannel; private DirectChannel directChannel; private XiaomiGatewayLight builtinLight; @@ -51,12 +63,33 @@ public XiaomiGateway(String ip) throws IOException, XaapiException { configureBuiltinDevices(); queryDevices(); } + public XiaomiGateway(String ip, String password) throws IOException, XaapiException { + this(ip); + configureCipher(password); + } private void configureBuiltinDevices() { builtinLight = new XiaomiGatewayLight(); builtinIlluminationSensor = new XiaomiGatewayIlluminationSensor(); } + private void configureCipher(String password) throws XaapiException { + try { + cipher = Cipher.getInstance("AES/CBC/NoPadding"); + final SecretKeySpec keySpec = new SecretKeySpec(password.getBytes(), "AES"); + final IvParameterSpec ivSpec = new IvParameterSpec(IV); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + } catch (NoSuchAlgorithmException e) { + throw new XaapiException("Cipher error: " + e.getMessage()); + } catch (NoSuchPaddingException e) { + throw new XaapiException("Cipher error: " + e.getMessage()); + } catch (InvalidAlgorithmParameterException e) { + throw new XaapiException("Cipher error: " + e.getMessage()); + } catch (InvalidKeyException e) { + throw new XaapiException("Cipher error: " + e.getMessage()); + } + } + private void queryDevices() throws XaapiException { try { directChannel.send(new GetIdListCommand().toBytes()); @@ -93,6 +126,34 @@ private boolean isMyself(String sid) { return sid.equals(this.sid); } + private void updateKey(String token) throws XaapiException { + if(cipher != null) { + try { + String keyAsHexString = Utility.toHexString(cipher.doFinal(token.getBytes(StandardCharsets.US_ASCII))); + key = Optional.of(keyAsHexString); + } catch (IllegalBlockSizeException e) { + throw new XaapiException("Cipher error: " + e.getMessage()); + } catch (BadPaddingException e) { + throw new XaapiException("Cipher error: " + e.getMessage()); + } + } else { + throw new XaapiException("Unable to update key without a cipher. Did you forget to set a password?"); + } + } + + void sendDataToDevice(SlaveDevice device, JsonObject data) throws XaapiException { + if(key.isPresent()) { + try { + directChannel.send(new WriteCommand(device, data, key.get()).toBytes()); + // TODO add handling for expired key + } catch (IOException e) { + throw new XaapiException("Network error: " + e.getMessage()); + } + } else { + throw new XaapiException("Unable to control device without a key. Did you forget to set a password?"); + } + } + private SlaveDevice readDevice(String sid) throws XaapiException { try { directChannel.send(new ReadCommand(sid).toBytes()); @@ -101,23 +162,23 @@ private SlaveDevice readDevice(String sid) throws XaapiException { switch(reply.model) { case "cube": - XiaomiCube cube = new XiaomiCube(sid); + XiaomiCube cube = new XiaomiCube(this, sid); cube.update(reply.data); return cube; case "magnet": - XiaomiDoorWindowSensor magnet = new XiaomiDoorWindowSensor(sid); + XiaomiDoorWindowSensor magnet = new XiaomiDoorWindowSensor(this, sid); magnet.update(reply.data); return magnet; case "plug": - XiaomiSocket plug = new XiaomiSocket(sid); + XiaomiSocket plug = new XiaomiSocket(this, sid); plug.update(reply.data); return plug; case "motion": - XiaomiMotionSensor motion = new XiaomiMotionSensor(sid); + XiaomiMotionSensor motion = new XiaomiMotionSensor(this, sid); motion.update(reply.data); return motion; case "switch": - XiaomiSwitchButton button = new XiaomiSwitchButton(sid); + XiaomiSwitchButton button = new XiaomiSwitchButton(this, sid); button.update(reply.data); return button; default: @@ -133,7 +194,8 @@ public void startReceivingUpdates(Executor executor) { executor.execute(() -> { while (continueReceivingUpdates) { try { - handleUpdate(GSON.fromJson(new String(incomingMulticastChannel.receive()), ReadReply.class)); + String received = new String(incomingMulticastChannel.receive()); + handleUpdate(GSON.fromJson(received, ReadReply.class), received); } catch (SocketTimeoutException e) { // ignore } catch (IOException e) { @@ -151,34 +213,46 @@ public void stopReceivingUpdates() { continueReceivingUpdates = false; } - private void handleUpdate(ReadReply update) throws XaapiException { + private void handleUpdate(Reply update, String received) throws XaapiException { switch(update.cmd) { case "report": + Report report = GSON.fromJson(received, Report.class); if(isMyself(update.sid)) { - handleBuiltinReport(update); + handleBuiltinReport(report); } else { - handleReport(update); + handleReport(report); } break; case "heartbeat": - handleHeartbeat(update); + if(isMyself(update.sid)) { + GatewayHeartbeat gatewayHeartbeat = GSON.fromJson(received, GatewayHeartbeat.class); + handleGatewayHeartbeat(gatewayHeartbeat); + } else { + SlaveDeviceHeartbeat slaveDeviceHeartbeat = GSON.fromJson(received, SlaveDeviceHeartbeat.class); + handleSlaveDeviceHeartbeat(slaveDeviceHeartbeat); + } break; default: throw new XaapiException("Unexpected update command: " + update.cmd); } } - private void handleReport(ReadReply update) { - // TODO handle updates about gateway itself - getDevice(update.sid).update(update.data); + private void handleReport(Report report) { + getDevice(report.sid).update(report.data); + } + + private void handleBuiltinReport(Report report) { + builtinLight.update(report.data); + builtinIlluminationSensor.update(report.data); } - private void handleBuiltinReport(ReadReply update) { - builtinLight.update(update.data); - builtinIlluminationSensor.update(update.data); + private void handleGatewayHeartbeat(GatewayHeartbeat gatewayHeartbeat) throws XaapiException { + if(cipher != null) { + updateKey(gatewayHeartbeat.token); + } } - private void handleHeartbeat(ReadReply update) { + private void handleSlaveDeviceHeartbeat(SlaveDeviceHeartbeat slaveDeviceHeartbeat) { // TODO implement } } \ No newline at end of file diff --git a/src/main/java/com/valashko/xaapi/device/XiaomiMotionSensor.java b/src/main/java/com/valashko/xaapi/device/XiaomiMotionSensor.java index 268c106..19fada9 100644 --- a/src/main/java/com/valashko/xaapi/device/XiaomiMotionSensor.java +++ b/src/main/java/com/valashko/xaapi/device/XiaomiMotionSensor.java @@ -12,8 +12,8 @@ public enum Status { private Status lastStatus; - public XiaomiMotionSensor(String sid) { - super(sid, Type.XiaomiMotionSensor); + public XiaomiMotionSensor(XiaomiGateway gateway, String sid) { + super(gateway, sid, Type.XiaomiMotionSensor); } @Override diff --git a/src/main/java/com/valashko/xaapi/device/XiaomiSocket.java b/src/main/java/com/valashko/xaapi/device/XiaomiSocket.java index a6f12ab..cfb6ed9 100644 --- a/src/main/java/com/valashko/xaapi/device/XiaomiSocket.java +++ b/src/main/java/com/valashko/xaapi/device/XiaomiSocket.java @@ -14,8 +14,8 @@ public enum Status { private Status lastStatus; - public XiaomiSocket(String sid) { - super(sid, Type.XiaomiSocket); + public XiaomiSocket(XiaomiGateway gateway, String sid) { + super(gateway, sid, Type.XiaomiSocket); } @Override @@ -48,4 +48,16 @@ void update(String data) { public Status getLastStatus() { return lastStatus; } + + public void turnOn() throws XaapiException { + JsonObject on = new JsonObject(); + on.addProperty("status", "on"); + gateway.sendDataToDevice(this, on); + } + + public void turnOff() throws XaapiException { + JsonObject off = new JsonObject(); + off.addProperty("status", "off"); + gateway.sendDataToDevice(this, off); + } } diff --git a/src/main/java/com/valashko/xaapi/device/XiaomiSwitchButton.java b/src/main/java/com/valashko/xaapi/device/XiaomiSwitchButton.java index 97e30cd..5f9230b 100644 --- a/src/main/java/com/valashko/xaapi/device/XiaomiSwitchButton.java +++ b/src/main/java/com/valashko/xaapi/device/XiaomiSwitchButton.java @@ -12,14 +12,16 @@ public class XiaomiSwitchButton extends SlaveDevice implements IInteractiveDevic public enum Action { Click, - DoubleClick + DoubleClick, + LongClickPress, + LongClickRelease } private Action lastAction; private HashMap> actionsCallbacks = new HashMap<>(); - public XiaomiSwitchButton(String sid) { - super(sid, Type.XiaomiSwitchButton); + public XiaomiSwitchButton(XiaomiGateway gateway, String sid) { + super(gateway, sid, Type.XiaomiSwitchButton); } @Override @@ -53,6 +55,12 @@ private void updateWithAction(String action) throws XaapiException { case "double_click": lastAction = Action.DoubleClick; break; + case "long_click_press": + lastAction = Action.LongClickPress; + break; + case "long_click_release": + lastAction = Action.LongClickRelease; + break; default: throw new XaapiException("Unknown action: " + action); } diff --git a/src/main/java/com/valashko/xaapi/reply/GatewayHeartbeat.java b/src/main/java/com/valashko/xaapi/reply/GatewayHeartbeat.java new file mode 100644 index 0000000..75b094b --- /dev/null +++ b/src/main/java/com/valashko/xaapi/reply/GatewayHeartbeat.java @@ -0,0 +1,8 @@ +package com.valashko.xaapi.reply; + +public class GatewayHeartbeat extends Reply { + public String model; + public String short_id; // NB: sometimes it is a string and sometimes a number + public String token; + public String data; +} diff --git a/src/main/java/com/valashko/xaapi/reply/GetIdListReply.java b/src/main/java/com/valashko/xaapi/reply/GetIdListReply.java index 780949b..67ef137 100644 --- a/src/main/java/com/valashko/xaapi/reply/GetIdListReply.java +++ b/src/main/java/com/valashko/xaapi/reply/GetIdListReply.java @@ -1,8 +1,6 @@ package com.valashko.xaapi.reply; -public class GetIdListReply { - public String cmd; - public String sid; - public String token; +public class GetIdListReply extends Reply { public String data; + public String token; } diff --git a/src/main/java/com/valashko/xaapi/reply/ReadReply.java b/src/main/java/com/valashko/xaapi/reply/ReadReply.java index 11c34c6..64995c1 100644 --- a/src/main/java/com/valashko/xaapi/reply/ReadReply.java +++ b/src/main/java/com/valashko/xaapi/reply/ReadReply.java @@ -1,9 +1,7 @@ package com.valashko.xaapi.reply; -public class ReadReply { - public String cmd; +public class ReadReply extends Reply { public String model; - public String sid; - public short short_id; + public String short_id; public String data; } diff --git a/src/main/java/com/valashko/xaapi/reply/Reply.java b/src/main/java/com/valashko/xaapi/reply/Reply.java new file mode 100644 index 0000000..e50cb6d --- /dev/null +++ b/src/main/java/com/valashko/xaapi/reply/Reply.java @@ -0,0 +1,6 @@ +package com.valashko.xaapi.reply; + +public class Reply { + public String cmd; + public String sid; +} diff --git a/src/main/java/com/valashko/xaapi/reply/Report.java b/src/main/java/com/valashko/xaapi/reply/Report.java new file mode 100644 index 0000000..e32d445 --- /dev/null +++ b/src/main/java/com/valashko/xaapi/reply/Report.java @@ -0,0 +1,7 @@ +package com.valashko.xaapi.reply; + +public class Report extends Reply { + public String model; + public short short_id; // NB: sometimes it is a string and sometimes a number + public String data; +} diff --git a/src/main/java/com/valashko/xaapi/reply/SlaveDeviceHeartbeat.java b/src/main/java/com/valashko/xaapi/reply/SlaveDeviceHeartbeat.java new file mode 100644 index 0000000..dae662c --- /dev/null +++ b/src/main/java/com/valashko/xaapi/reply/SlaveDeviceHeartbeat.java @@ -0,0 +1,5 @@ +package com.valashko.xaapi.reply; + +public class SlaveDeviceHeartbeat extends Reply { + // TODO implement +} diff --git a/src/main/java/com/valashko/xaapi/reply/WhoisReply.java b/src/main/java/com/valashko/xaapi/reply/WhoisReply.java index 2fcedcf..dbcee29 100644 --- a/src/main/java/com/valashko/xaapi/reply/WhoisReply.java +++ b/src/main/java/com/valashko/xaapi/reply/WhoisReply.java @@ -1,9 +1,7 @@ package com.valashko.xaapi.reply; -public class WhoisReply { - public String cmd; +public class WhoisReply extends Reply { public String port; - public String sid; public String model; public String proto_version; public String ip;