Rollease Acmeda Automate Pulse hub integration

While the cover still does not show what I expect, automations do work.

Im not sure grouping with covers will work, I had to introduce a delay into my automations if I had more than 2 blinds to control at once.

Grouping is working well for me so far. I can operate ten shades at the same time.

Yes, I can also report that creating a cover group works fine. I have 7 blinds and they all open or close at the same time when I use the group. :slight_smile:

However, what is not working, is the battery sensor. It shows as unavailable for the shades but there are no errors in the logs.

Is anyone with the v2 hub using this integration or just the HomeKit solution?

It looks like this integration was added to HA in 0.111. I have a v2 hub and I’m currently using the HomeKit solution, but since that doesn’t display battery levels, I was excited to try this integration out. Unfortunately, the integration does not discover my hub, and since there aren’t any options to configure, it just won’t work for me.

I’m using the HomeKit solution. The integration doesn’t find my hub. I havent dug too far into it though as the HomeKit solution works ok (doesn’t show battery, or closed/open state it gets stuck on opening or closing).

Great work with the integration. Unfortunately it did not autodiscover my hub when it was placed on a different VLAN.

I then try setting it up on the same VLAN and it does work perfectly.

I would like to request an option to manually specify the IP address of the hub instead of solely relying on autodiscovery, when adding the integration.

Thanks!
Correct Horse Battery Staple.

Are you using v1 or v2 of the hub when auto-discovery worked?

I have the Pulse Hub v1, the black one.

I actually have both hubs, but I migrated to v2 once I realized that HomeKit worked with it. I might pull v1 out just to give it a try.

That would be great, thank you. Just to clarify, this is what I tested:

Scenario 1: Home Assistant and Pulse Hub on same VLAN
HASS IP 192.168.1.10
Pulse IP 192.168.1.11
Result: Autodiscover and integration works perfectly.

Scenario 2: Pulse Hub and Home Assistant in different VLANS
HASS IP: 192.168.1.10
Pulse IP: 192.168.2.10
Result: Autodiscover does not work and integration cannot be configured.

Note that firewall rules, mDNS and masquerade are enabled and this configuration is working with other integrations, but not with this one.

An alternative could be for HASS to request to manually the IP address of the hub after autodicovery fails.

I’m just checking whether the version 2 hub is working with the integration, as a number of people above are saying their v2 hub is not discovered. Has this changed?

I have the v2 and it doesn’t work for me. I think the two have different protocols for communicating, with v1 only communicating over a serial connection, whereas v2 will communicate over the local lan. I have mine attached to SmartThings via its native integration and then into HA. I couldn’t get the HomeKit to work with my phone already attached to it.

The V2 hub does still support the serial connection. I’ve managed to Telnet into it and control the hub sending commands. Just need to telnet in on Port 1487, can pretty much issue the same commands as in the guide for the Hub 1.

I’m definitely no python expert, but just playing around I can get the list of motors and their current position.

import telnetlib

findblinds = "!000v?;"
tn = telnetlib.Telnet("10.0.0.188",1487, 5)
tn.write(findblinds.encode('ascii') + b"\r")
n = tn.read_until(b"\r", 2)
blinds = n.decode("ascii").strip()
allitems= blinds.split(';')
motors = []

for itm in allitems:
  if "vD" in itm or "vA" in itm:
     blind = itm[1:4]
     motors.append(blind)
     print("Motor: " +  blind)

# Get Current Position of All Blinds

for motor in motors:
  command = "!" + motor + "r?;"
  tn.write(command.encode("ascii") + b"\n")
  status = tn.read_until(b"\n",2)
  str_status = status.decode("ascii").strip()
  position = str_status[5:8]
  print("Motor: " + motor + " Position: " + position)

Although it’s a bit of fun playing around with it, not 100% not a full integration is necessary now there are other options that work. There are just two issues with the HomeKit integration in that the battery level doesn’t come through, and the blinds will always show Opening/Closing and not open/closed.

I got around the latter issue by building an appdaemon app that force sets the state to open/closed when the position hits 0 or 100.

Thank you to those working on a native integration with HA.

I wanted to point out that Acmeda have testing software available called Pulse LINQ. It is available here:
https://www.automateshades.com/au/pulse-linq-tool/

It appears to be an electron app that autodetects v2 Hubs and allows you to send/receive commands. You can use the ‘View’ menu to turn on developer tools which exposes the underlying javascript. There is a hubworker.js file which contains some command logic:

// Web Workers can't find the node_modules by default.
// This mess of code adds the correct node module path to the module resolver
let path = require("path");
module.paths.push(path.resolve("node_modules"));
module.paths.push(path.resolve("../node_modules"));
module.paths.push(
  path.resolve(__dirname, "..", "..", "electron", "node_modules")
);
module.paths.push(
  path.resolve(__dirname, "..", "..", "electron.asar", "node_modules")
);
module.paths.push(path.resolve(__dirname, "..", "..", "app", "node_modules"));
module.paths.push(
  path.resolve(__dirname, "..", "..", "app.asar", "node_modules")
);
path = undefined;

let motorPairSequence = 1;

const net = require("net");
const EventEmitter = require("events");
const queue = require("queue");

const hubCommands = {
  hubNameQuery: "!000NAME?;",
  hubMotorQuery: "!000v?;",
  motorPairingRequest: "!000&;"
};

const hubCommandRegex = {
  unpairQuery: /\w{3}#/g,
  deleteQuery: /\w{3}\$/g
};

const hubResponseRegex = {
  versionResponse: /\w{3}v\w{3}/g,
  positionResponse: /\w{3}r\d{3}b\d{3},\w{3}/g,
  motorErrorResponse: /\w{3}E[0-9A-Za-z _.-]+/g,
  hubNameResponse: /000NAME[0-9A-Za-z _.-]+/g,
  motorNameResponse: /\w{3}NAME[0-9A-Za-z _.-]+/g,
  motorRoomResponse: /\w{3}ROOM[0-9A-Za-z _.-]+/g,
  motorPairingResponse: /\w{3}A/g,
  motorMovementResponse: /\w{3}r\d{3}b\d{3}/g
};

const defaultMotorName = "My Shade";

class Hub {
  constructor(props) {
    this.isConnected = false;
    this.socket = null;
    this.retryAttempt = null;
    this.rooms = {};
    this.motors = {};
    this.name = null;
    this.responses = [];
    this.motorPairNumber = 1;
    this.motorTestResponses = 0;

    this.setupDelay = 3000;
    this.motorDelay = 1000;
    this.retryDelay = 2000;
    this.maxRetries = 5;

    this.address = props.address;
    this.port = props.port;
    this.eventBus = props.eventBus;
    this.reconnectionAttemptsCount = 0;
  }

  _clearResponses() {
    this.socket.removeAllListeners("data");
    this.responses = [];
    this.socket.on("data", data => {
      this.eventBus.emit("data", this.address, data.toString());
      this.responses.push(data.toString());
    });
  }

  connect() {
    try {
      this.socket = net.connect(this.port, this.address);

      this.socket.on("connect", () => {
        this.isConnected = true;
        this.eventBus.emit("connect", this.address);

        this._clearResponses();
        this.setup();
      });

      this.socket.on("error", (e) => {
        console.log("SOCKET ERROR ON " + this.address + ": ", e);
        const errorString = this.reconnectionAttemptsCount < this.maxRetries ?
              `Lost connection to ${this.address}:${this.port}. Attempting to connect again...` :
              `Lost connection to ${this.address}:${this.port}. Try power cycling the hub and reconnecting.`;
        console.log(errorString);

        this.disconnect();
        this.eventBus.emit("disconnect", this.address);

        postMessage({
          type: "toastrError",
          text: errorString
        });

        console.log("reconnectionAttemptsCount: " + this.reconnectionAttemptsCount);

        if (this.reconnectionAttemptsCount < this.maxRetries) {
          this.reconnectionAttemptsCount++;
          this.retryAttempt = setTimeout(() => {
            this.connect();
          }, this.retryDelay);
        }

      });

      return true;
    } catch {
      return false;
    }
  }

  disconnect() {
    this.isConnected = false;
    this.socket.pause();
    this.socket.end();
  }

  cancelRetryAttempt() {
    if (this.retryAttempt !== null) {
      clearTimeout(this.retryAttempt);
    }
    this.retryAttempt = null;
    this.reconnectionAttemptsCount = 0;
  }

  setup(state = "getHubMetadata") {
    console.log(state);

    if (!this.isConnected) return;

    if (state === "getHubMetadata") {
      this._clearResponses();
      this.sendCommand(hubCommands.hubNameQuery);
      state = "getHubMetadataResponse";
    } else if (state === "getHubMetadataResponse") {
      let responseString = "";

      this.responses.forEach(response => (responseString += response));

      console.log("RESPONSE STRING: " + responseString);

      const nameResponses = responseString.match(
        hubResponseRegex.hubNameResponse
      );

      console.log("NAME RESPONSES: " + JSON.stringify(nameResponses));

      if (nameResponses !== null) {
        nameResponses.forEach(nameResponse => {
          this.name = nameResponse.split("NAME")[1].trim();
        });

        state = "getMotors";
      }

      else {
        state = "getHubMetadata";   //re-run the 'hub name' query if no name response is detected
      }
    } else if (state === "getMotors") {
      this.sendCommand(hubCommands.hubMotorQuery);
      state = "getMotorsResponse";
    } else if (state === "getMotorsResponse") {
      let responseString = "";

      this.responses.forEach(response => (responseString += response));

      const versionResponses = responseString.match(
        hubResponseRegex.versionResponse
      );

      let foundNewMotor = false;

      if (versionResponses !== null) {
        versionResponses.forEach(versionResponse => {
          const versionInfo = versionResponse.split("v", 2);

          const deviceID = versionInfo[0].toUpperCase();
          const deviceType = versionInfo[1][0].toUpperCase();
          const deviceVersion = versionInfo[1].slice(1, 3).toUpperCase();

          // Device type "B" is bridge/hub - omit it from our results
          let deviceObject = null;

          if (deviceType !== "B") {
            deviceObject = {
              type: deviceType,
              version: deviceVersion
            };

            if (!(deviceID in this.motors)) {
              foundNewMotor = true;
              this.motors[deviceID] = deviceObject;
            }
          }
        });

        if (foundNewMotor) {
          state = "getMotorsResponse";
        } else {
          state = "getMotorMetadata";
        }
      }
    } else if (state === "getMotorMetadata") {
      this._clearResponses();
      Object.keys(this.motors).forEach(motor => {
        this.sendCommand(`!${motor}NAME?;`);
        this.sendCommand(`!${motor}ROOM?;`);
      });
      state = "getMotorMetadataResponse";
    } else if (state === "getMotorMetadataResponse") {
      let responseString = "";

      this.responses.forEach(response => (responseString += response));

      const nameResponses = responseString.match(
        hubResponseRegex.motorNameResponse
      );

      let foundNewName = false;

      if (nameResponses !== null) {
        nameResponses.forEach(nameResponse => {
          const versionInfo = nameResponse.split("NAME", 2);

          const deviceID = versionInfo[0].toUpperCase();
          const deviceName = versionInfo[1].trim();

          if (deviceID in this.motors && !("name" in this.motors[deviceID])) {
            foundNewName = true;
            this.motors[deviceID].name = deviceName;
          }
        });
      }

      const roomResponses = responseString.match(
        hubResponseRegex.motorRoomResponse
      );

      let foundNewRoom = false;

      if (roomResponses !== null) {
        roomResponses.forEach(roomResponse => {
          const versionInfo = roomResponse.split("ROOM", 2);

          const deviceID = versionInfo[0].toUpperCase();
          const deviceRoom = versionInfo[1].trim();

          if (!(deviceRoom in this.rooms)) {
            foundNewRoom = true;
            this.rooms[deviceRoom] = {};
          }

          if (deviceID in this.motors && !("room" in this.motors[deviceID])) {
            this.motors[deviceID].room = deviceRoom;
          }
        });
      }

      if (foundNewName || foundNewRoom) {
        state = "getMotorMetadataResponse";
      } else {
        state = "finalize";
      }
    } else if (state === "finalize") {
      this._clearResponses();
      this.setupRooms();

      console.log("Finished setting up hub " + this.address + ".");

      postMessage({
        type: "setupHubInTree",
        hub: {
          address: this.address,
          name: this.name,
          rooms: this.rooms
        }
      });

      this.socket.removeAllListeners("data");
      this.socket.on("data", data => {
        this.eventBus.emit("data", this.address, data.toString());
      });

      state = "finished";
    }

    else if (state === "finished") {
      this.reconnectionAttemptsCount = 0;
      this.retryAttempt = null;
    }

    if (state !== "finished") {
      setTimeout(() => this.setup(state), this.setupDelay);
    }
  }

  sendCommand(command) {
    if (!this.isConnected) return;

    this.socket.write(command);

    postMessage({
      type: "response",
      response: `Sent "${command}" to hub ${this.address}.`
    });
  }

  setupRooms() {
    this.rooms = {};

    Object.keys(this.motors).forEach(motor => {
      const motorObject = this.motors[motor];

      if (this.motors[motor].hasOwnProperty("room")) {
        if (!(this.motors[motor].room in this.rooms)) {
          this.rooms[this.motors[motor].room] = {};
        }

        this.rooms[this.motors[motor].room][motor] = {
          type: motorObject.type,
          version: motorObject.version,
          name: motorObject.name
        };
      }
    });
  }

  pairMotor(state = "sendPairCommand", tries = 0) {
    if (state === "sendPairCommand") {
      this._clearResponses();
      this.sendCommand(hubCommands.motorPairingRequest);
      tries = 0;
      state = "getMotorResponse";
    } else if (state === "getMotorResponse") {
      let responseString = "";

      this.responses.forEach(response => (responseString += response));

      const versionResponses = responseString.match(
        hubResponseRegex.versionResponse
      );

      const pairingResponses = responseString.match(
        hubResponseRegex.motorPairingResponse
      );

      let foundNewMotor = false;

      if (versionResponses !== null && pairingResponses !== null) {
        versionResponses.forEach(versionResponse => {
          const versionInfo = versionResponse.split("v", 2);

          const deviceID = versionInfo[0].toUpperCase();
          const deviceType = versionInfo[1][0].toUpperCase();
          const deviceVersion = versionInfo[1].slice(1, 3).toUpperCase();

          // Device type "B" is bridge/hub - omit it from our results
          let deviceObject = null;

          if (deviceType !== "B") {
            deviceObject = {
              type: deviceType,
              version: deviceVersion,
              room: "default"
            };

            if (!(deviceObject.room in this.rooms)) {
              this.rooms[deviceObject.room] = {};
            }

            if (!(deviceID in this.motors)) {
              foundNewMotor = true;
              this.motors[deviceID] = deviceObject;
            }
          }
        });

        if (foundNewMotor) {
          state = "getMotorMetadata";
          tries = 0;
        } else {
          state = "getMotorResponse";
        }
      }
    } else if (state === "getMotorMetadata") {
      this._clearResponses();
      Object.keys(this.motors).forEach(motor => {
        if (!motor.hasOwnProperty("name")) {
          this.sendCommand(`!${motor}NAME?;`);
        }
      });
      state = "getMotorMetadataResponse";
    } else if (state === "getMotorMetadataResponse") {
      let responseString = "";

      this.responses.forEach(response => (responseString += response));

      const nameResponses = responseString.match(
        hubResponseRegex.motorNameResponse
      );

      let foundNewName = false;

      if (nameResponses !== null) {
        nameResponses.forEach(nameResponse => {
          const versionInfo = nameResponse.split("NAME", 2);

          const deviceID = versionInfo[0].toUpperCase();
          let deviceName = versionInfo[1].trim();

          if (deviceID in this.motors && !("name" in this.motors[deviceID])) {
            foundNewName = true;
            if (deviceName === defaultMotorName) {
              deviceName = `${defaultMotorName} ${motorPairSequence}`;
              motorPairSequence++;
            }
            this.motors[deviceID].name = deviceName;
          }
        });
      }

      if (foundNewName) {
        state = "finalize";
        tries = 0;
      } else {
        state = "getMotorMetadataResponse";
      }
    } else if (state === "finalize") {
      this._clearResponses();
      this.setupRooms();

      postMessage({
        type: "setupHubInTree",
        hub: {
          address: this.address,
          name: this.name,
          rooms: this.rooms
        }
      });
      state = "finished";
    }

    if (tries === 5) {
      this.eventBus.emit(
        "warning",
        "Device was not paired with the hub successfully within the allotted time. Please try again."
      );
    } else {
      if (state !== "finished") {
        setTimeout(() => this.pairMotor(state, tries + 1), this.motorDelay);
      }
    }
  }

  removeMotor(command, state = "sendRemoveCommand", tries = 0) {
    if (state === "sendRemoveCommand") {
      this._clearResponses();
      this.sendCommand(command);
      state = "getMotorResponse";
      tries = 0;
    } else if (state === "getMotorResponse") {
      let responseString = "";

      this.responses.forEach(response => (responseString += response));

      const pairingResponses = responseString.match(
        hubResponseRegex.motorPairingResponse
      );

      let foundMotor = false;

      if (pairingResponses !== null) {
        pairingResponses.forEach(pairingResponse => {
          const deviceID = pairingResponse.slice(0, 3);

          if (deviceID in this.motors) {
            foundMotor = true;
            delete this.motors[deviceID];
          }
        });

        if (foundMotor) {
          state = "finalize";
          tries = 0;
        } else {
          state = "getMotorResponse";
        }
      }
    } else if (state === "finalize") {
      this._clearResponses();
      this.setupRooms();

      postMessage({
        type: "setupHubInTree",
        hub: {
          address: this.address,
          name: this.name,
          rooms: this.rooms
        }
      });
      state = "finished";
    }

    if (tries === 5) {
      this.eventBus.emit(
        "warning",
        "Device was not removed from the hub successfully within the allotted time. Please try again."
      );
    } else {
      if (state !== "finished") {
        setTimeout(
          () => this.removeMotor(command, state, tries + 1),
          this.motorDelay
        );
      }
    }
  }

  testMotors(motors, percent, state = "sendMoveCommands", tries = 0) {
    if (state === "sendMoveCommands") {
      this.motorTestResponses = 0;
      this._clearResponses();
      for (let motor in motors) {
        this.sendCommand(motors[motor].command);
      }
      state = "getMotorResponse";
      tries = 0;
    } else if (state === "getMotorResponse") {
      let responseString = "";

      this.responses.forEach(response => (responseString += response));

      const errorResponses = responseString.match(
        hubResponseRegex.motorErrorResponse
      );

      if (errorResponses !== null) {
        errorResponses.forEach(errorResponse => {
          const deviceID = errorResponse.slice(0, 3);

          if (deviceID in motors) {
            postMessage({
              type: "updateMotorTestStatus",
              status: "failure",
              desc: "The motor returned an error indicating it could not move to the desired position.",
              deviceID
            });
            delete motors[deviceID];
          }
        });
      }

      const movementResponses = responseString.match(
        hubResponseRegex.motorMovementResponse
      );

      if (movementResponses !== null) {
        movementResponses.forEach(movementResponse => {
          const deviceID = movementResponse.slice(0, 3);
          const movementAmount = parseInt(movementResponse.slice(4, 7));

          if (deviceID in motors) {
            this.motorTestResponses++;
            if (movementAmount - parseInt(percent) <= 5) {
              postMessage({
                type: "updateMotorTestStatus",
                status: "success",
                desc: "The motor returned a response indicating it has successfully moved to the target position.",
                deviceID
              });
            } else {
              postMessage({
                type: "updateMotorTestStatus",
                status: "failure",
                desc: "The motor returned a response indicating that it did not successfully move to the target position.",
                deviceID
              });
            }
            delete motors[deviceID];
          }
        });

        if (this.motorTestResponses === Object.keys(motors).length + 1) {
          state = "finalize";
          tries = 0;
        } else {
          state = "getMotorResponse";
        }
      }

    } else if (state === "finalize") {
      state = "finished";

      postMessage({
        type: "finishTest"
      });

      for (let motor in motors) {
        postMessage({
          type: "updateMotorTestStatus",
          status: "failure",
          desc: "The motor did not respond during the test period.",
          deviceID: motor
        });
      }
    }

    if (tries === 10) {
      for (let motor in motors) {
        postMessage({
          type: "updateMotorTestStatus",
          status: "failure",
          deviceID: motor
        });
      }
    } else {
      if (state !== "finished") {
        setTimeout(
          () => this.testMotors(motors, percent, state, tries + 1),
          this.setupDelay
        );
      }
    }
  }
}

class HubManager {
  constructor(props) {
    this.port = 1487;
    this._debug = false;
    this.hubs = {};
    this.eventBus = new EventEmitter();
    this.commandQueue = queue({
      autostart: true,
      concurrency: 1,
      timeout: 500
    });
    this.effectRunning = false;
    this.effectMotors = [];
    this.responses = [];
    this.effect = null;
    this.waitUntilTime = null;
    this.motorsMoved = 0;
    this.timeouts = [];

    this._debug = props.debug;

    motorPairSequence = 1;

    this.eventBus.on("connect", address => {
      console.log(`${address} connected to HubManager.`);
      postMessage({ type: "hubConnected", hubAddress: address });
    });

    this.eventBus.on("disconnect", address => {
      console.log(`${address} disconnected from HubManager.`);
      postMessage({ type: "hubDisconnected", hubAddress: address });
    });

    this.eventBus.on("data", (hubAddress, data) => {
      if (hubAddress in this.hubs) {
        postMessage({
          type: "response",
          response: `Received "${data}" from hub ${hubAddress}.`
        });

        if (this.effectRunning) {
          this.responses.push({
            data,
            hubAddress
          });
        }
      }
    });

    this.eventBus.on("foundMotor", (hubAddress, motor) => {
      postMessage({
        type: "foundMotor",
        hubAddress,
        motor
      });
    });

    this.eventBus.on("pairedMotor", (hubAddress, motor) => {
      postMessage({
        type: "pairedMotor",
        hubAddress,
        motor
      });
    });

    this.eventBus.on("warning", message => {
      postMessage({
        type: "warning",
        message
      });
    });

    console.log("Hub manager is ready!");
  }

  connectHub(address) {
    console.log("ADDRESS TO CONNECT TO: " + address)
    address = address.trim();

    if (!(address in this.hubs)) {
      let hub = new Hub({
        address: address,
        port: this.port,
        eventBus: this.eventBus,
        debug: this._debug
      });

      hub.connect();

      this.hubs[address] = hub;

      return hub;
    }
  }

  disconnectHub(address) {
    address = address.trim();

    if (address in this.hubs) {
      this.hubs[address].disconnect();
      this.hubs[address].cancelRetryAttempt();
      delete this.hubs[address];

      postMessage({ type: "hubDisconnected", hubAddress: address });
    } else {
      console.error("Cannot disconnect hub that is not connected to manager.");
    }
  }

  disconnectAllHubs() {
    Object.keys(this.hubs).forEach(hubAddress =>
      this.disconnectHub(hubAddress)
    );

    this.hubs = {};

    console.log("Disconnected from all hubs.");
  }

  queueCommand(command) {
    this.commandQueue.push(() => {
      this.hubs[command.hub].sendCommand(command.commandText);
    });
  }

  startEffect(effect, motors) {
    this.effectMotors = motors;
    this.effectRunning = true;
    this.effect = effect;

    this.setupMotorsForEffect();
  }

  setupMotorsForEffect() {
    if (!this.effectRunning) return;

    postMessage({
      type: "response",
      response:
        "Setting up Wave effect. Moving all motors to top. Effect starts in 30 seconds."
    });

    let commandDelay = 0;
    // Send the motors to their top position before starting effect
    this.effectMotors.forEach(motor => {
      const motorParts = motor.split("_");

      const timeout = setTimeout(
        () =>
          this.queueCommand({
            hub: motorParts[0],
            commandText: `!${motorParts[1]}o;`
          }),
        commandDelay
      );

      this.timeouts.push(timeout);

      commandDelay += 500;
    });

    setTimeout(() => this.runEffect(), 30 * 1000);
    this.timeouts.splice(0, this.timeouts.length);
  }

  stopEffect() {
    this.effectRunning = false;
    this.effectMotors = [];
    this.commandQueue.end();

    this.timeouts.forEach(timeout => {
      clearTimeout(timeout);
    });

    this.timeouts.splice(0, this.timeouts.length);
  }

  runEffect(state = "running", direction = "forward") {
    if (!this.effectRunning) return;

    if (this.effect.name === "wave") {
      if (state === "running") {
        this.waitUntilTime = null;
        this.responses = [];
        let delay = 0;

        this.effectMotors.forEach(motor => {
          const motorParts = motor.split("_");

          let command;
          if (direction === "forward") {
            command = `!${motorParts[1]}c;`;
          } else if (direction === "backward") {
            command = `!${motorParts[1]}o;`;
          }

          const timeout = setTimeout(() => {
            this.queueCommand({
              hub: motorParts[0],
              commandText: command
            });

            this.motorsMoved++;
          }, delay);

          this.timeouts.push(timeout);

          delay += this.effect.motorInterval * 1000;
        });

        state = "waitForMotors";
      } else if (state === "waitForMotors") {
        if (this.motorsMoved === this.effectMotors.length) {
          this.motorsMoved = 0;
          state = "waitForCycle";
        }
      } else if (state === "waitForCycle") {
        if (this.waitUntilTime === null) {
          this.waitUntilTime = Date.now() + this.effect.cycleTime * 1000;

          postMessage({
            type: "response",
            response: `Waiting for next wave cycle (${
              this.effect.cycleTime
            } seconds).`
          });
        } else {
          if (Date.now() >= this.waitUntilTime) {
            this.timeouts.splice(0, this.timeouts.length);
            this.waitUntilTime = null;
            this.effectMotors.reverse();
            state = "running";
            direction = direction === "forward" ? "backward" : "forward";
          }
        }
      }
    }

    setTimeout(() => this.runEffect(state, direction), 500);
  }

  pairMotor(command) {
    this.hubs[command.hub].pairMotor();
  }

  removeMotor(command) {
    this.hubs[command.hub].removeMotor(command.commandText);
  }

  testMotors(percent, motors) {
    const hubs = {};
    for (let i = 0; i < motors.length; i++) {
      const destinationInfo = motors[i].split("_", 2);
      const hubAddress = destinationInfo[0];
      const motorId = destinationInfo[1];

      let percentString = percent.trim();
      if (percent.length === 2) {
        percentString = "0" + percent;
      } else if (percent.length === 1) {
        percentString = "00" + percent;
      }

      if (hubAddress in hubs) {
        hubs[hubAddress].motors[motorId] = {
          command: `!${destinationInfo[1]}m${percentString};`
        };
      }

      else {
        const motors = {};
        motors[motorId] = {
          command: `!${destinationInfo[1]}m${percentString};`
        };

        hubs[hubAddress] = {
          motors,
          destinationPercent: percentString
        };
      }
    }

    for (const hub in hubs) {
      this.hubs[hub].testMotors(hubs[hub].motors, percent);
    }
  }
}

// Worker class code starts here
const hubManager = new HubManager({ debug: false });

self.addEventListener("message", message => {
  handleMessage(message.data);
});

self.addEventListener("SIGHUP", () => this.close());

const handleMessage = function(message) {

  if (message.type === "connectHub") {
    for (var i = 0; i < message.hubs.length; i++) {
      const hub = message.hubs[i];
      setTimeout(() => {
        hubManager.connectHub(hub);
      }, i*1000);
    }
  } else if (message.type === "command") {
    for (let i = 0; i < message.commands.length; i++) {

      if (message.commands[i].commandText === hubCommands.motorPairingRequest) {
        hubManager.pairMotor(message.commands[i]);
      }

      else {
        const deleteCommands = message.commands[i].commandText.match(
          hubCommandRegex.deleteQuery
        );

        const unpairCommands = message.commands[i].commandText.match(
          hubCommandRegex.unpairQuery
        );

        if (deleteCommands !== null || unpairCommands !== null) {
          hubManager.removeMotor(message.commands[i]);
        }

        else {
          hubManager.queueCommand(message.commands[i]);
        }
      }
    }
  } else if (message.type === "disconnectHub") {
    for (let i = 0; i < message.hubs.length; i++) {
      hubManager.disconnectHub(message.hubs[i]);
    }
  } else if (message.type === "disconnectAllHubs") {
    hubManager.disconnectAllHubs();
  } else if (message.type === "startEffect") {
    hubManager.startEffect(message.effect, message.motors);
  } else if (message.type === "stopEffect") {
    hubManager.stopEffect();
  } else if (message.type === "testMotors") {
    hubManager.testMotors(message.percent, message.motors);
  } else {
    console.error("Unsupported message received from master process.");
  }
};

Further update, I have found another file hubHelpers.js in Pulse LINQ which has some more useful commands for finding and communicating with v2 hubs. To find a hub it basically runs a network scan with evilscan on your network looking for a response on port 1487. Looking briefly at the aiopulse code maybe the port just needs to be changed. I will see if I can get aiopulse working with my v2 Hub.

import { remote } from "electron";
import { readFile, writeFile } from "fs";
import evilscan from "evilscan";

const { dialog } = remote;

export default {
  createHubScanner: function(address) {
    let host = address;
    const hostIsSubnet = host.indexOf("/") !== -1;

    if (!hostIsSubnet) {
      const hostIPSections = host.split(".");

      host = `${hostIPSections[0]}.${hostIPSections[1]}.${
        hostIPSections[2]
      }.0/24`;
    }

    const options = {
      target: host,
      port: 1487,
      status: "Open", // Timeout, Refused, Open, Unreachable
      banner: true
    };
    return new evilscan(options);
  },
  // Query Input - Command = 
  buildCommandFromQuery: function(query, selectedMotors) {
    const commandQueue = [];

    for (let i = 0; i < selectedMotors.length; i++) {
      const destinationInfo = selectedMotors[i].split("_", 2);
      const hubAddress = destinationInfo[0];

      let command = "";
      if (query.includes("!") && query.includes(";")) {
        command = query;
      } else {
        command = `!${destinationInfo[1]}${query}`;
      }

      const commandObject = { hub: hubAddress, commandText: command };

      if (
        !commandQueue.find(v => {
          return (
            v.hub === commandObject.hub &&
            v.commandText === commandObject.commandText
          );
        })
      ) {
        commandQueue.push(commandObject);
      }
    }
    return commandQueue;
  },
  //  Percentage Textbox - Command = 
  buildCommandFromPercentage: function(query, selectedMotors) {
    const commandQueue = [];

    for (let i = 0; i < selectedMotors.length; i++) {
      const destinationInfo = selectedMotors[i].split("_", 2);
      const hubAddress = destinationInfo[0];

    let command = "";
    if (query.includes("!") && query.includes(";")) {
      command = query;
    } else {
      command = `!${destinationInfo[1]}m${query};`;
    }

    const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  //  Stop Button - Command = 
  buildCommandForStop: function(query, selectedMotors) {
    const commandQueue = [];

    for (let i = 0; i < selectedMotors.length; i++) {
      const destinationInfo = selectedMotors[i].split("_", 2);
      const hubAddress = destinationInfo[0];

    let command = `!${destinationInfo[1]}s;`;

    const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  //  Up Button - Command = 
  buildCommandForUp: function(query, selectedMotors) {
    const commandQueue = [];

    for (let i = 0; i < selectedMotors.length; i++) {
      const destinationInfo = selectedMotors[i].split("_", 2);
      const hubAddress = destinationInfo[0];

    let command = `!${destinationInfo[1]}o;`;

    const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  //  Jog Up Button - Command = 
  buildCommandForJogUp: function(query, selectedMotors) {
    const commandQueue = [];

    for (let i = 0; i < selectedMotors.length; i++) {
      const destinationInfo = selectedMotors[i].split("_", 2);
      const hubAddress = destinationInfo[0];

    let command = `!${destinationInfo[1]}oA;`;

    const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  //  Jog Down Button - Command = 
  buildCommandForJogDown: function(query, selectedMotors) {
    const commandQueue = [];

    for (let i = 0; i < selectedMotors.length; i++) {
      const destinationInfo = selectedMotors[i].split("_", 2);
      const hubAddress = destinationInfo[0];

    let command = `!${destinationInfo[1]}cA;`;

    const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  //  Reverse Button - Command = 
  buildCommandForReverse: function(query, selectedMotors) {
    const commandQueue = [];

    for (let i = 0; i < selectedMotors.length; i++) {
      const destinationInfo = selectedMotors[i].split("_", 2);
      const hubAddress = destinationInfo[0];

    let command = `!${destinationInfo[1]}pM02;`;

    const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  //  Stop Button - Command = 
  buildCommandForDown: function(query, selectedMotors) {
    const commandQueue = [];

      for (let i = 0; i < selectedMotors.length; i++) {
        const destinationInfo = selectedMotors[i].split("_", 2);
        const hubAddress = destinationInfo[0];

      let command = `!${destinationInfo[1]}c;`;

      const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
   ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  // Pairing Button - Command = 
  buildCommandForPairing: function(query, selectedHubs) {
    const commandQueue = [];

      for (let i = 0; i < selectedHubs.length; i++) {
        const destinationInfo = selectedHubs[i].split("_", 2);
        const hubAddress = destinationInfo[0];

      let command = `!000&;`;

      const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  //  Test Button - Command = 
  buildCommandForTest: function(query, selectedMotors) {
    const commandQueue = [];

      for (let i = 0; i < selectedMotors.length; i++) {
        const destinationInfo = selectedMotors[i].split("_", 2);
        const hubAddress = destinationInfo[0];

      let command = `!${destinationInfo[1]}m050;`;

      const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  //  Set Upper Limit - Command = 
  buildCommandForSetUpper: function(query, selectedMotors) {
    const commandQueue = [];

      for (let i = 0; i < selectedMotors.length; i++) {
        const destinationInfo = selectedMotors[i].split("_", 2);
        const hubAddress = destinationInfo[0];

      let command = `!${destinationInfo[1]}pEoH;`;

      const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
//  Set Lower Limit - Command = 
  buildCommandForSetLower: function(query, selectedMotors) {
    const commandQueue = [];

      for (let i = 0; i < selectedMotors.length; i++) {
        const destinationInfo = selectedMotors[i].split("_", 2);
        const hubAddress = destinationInfo[0];

      let command = `!${destinationInfo[1]}pEcH;`;

      const commandObject = { hub: hubAddress, commandText: command };

    if (
      !commandQueue.find(v => {
        return (
          v.hub === commandObject.hub &&
          v.commandText === commandObject.commandText
        );  
      })
    ) {
      commandQueue.push(commandObject);
    }
  }
  return commandQueue;
  },
  exportTree: function(tree, callback) {
    dialog.showSaveDialog(
      {
        filters: [{ name: "*.json", extensions: ["json"] }]
      },
      filename => {
        if (filename === undefined)
          return console.log("Please provide a file name.");

        const jsonString = JSON.stringify(tree, null, 2);

        writeFile(filename, jsonString, err => {
          if (err) return console.log(err);
          callback("File saved to " + filename, err);
        });
      }
    );
  },
  importTree: function(callback) {
    dialog.showOpenDialog(
      {
        filters: [{ name: "*.json", extensions: ["json"] }]
      },
      filenames => {
        if (filenames === undefined) return console.log("No name given");
        readFile(filenames[0], "utf-8", (err, data) => {
          callback(err, JSON.parse(data));
        });
      }
    );
  },
  exportHubResponses: function(responses, callback) {
    dialog.showSaveDialog(
      {
        filters: [{ name: "*.txt", extensions: ["txt"] }]
      },
      filename => {
        if (filename === undefined)
          return console.log("Please provide a file name.");

        let logString = "";
        responses.forEach(response => {
          logString += `[${response.date}] ${response.text}\r\n`;
        });

        writeFile(filename, logString, err => {
          if (err) return console.log(err);
          callback("File saved to " + filename, err);
        });
      }
    );
  }
};

@pss Did you have any luck with this? I have recently got the Pulse 2 as well and keen to get it integrated with HA. Do you know if anyone has asked for the current protocol documentation? I tried the v1 doc, and some commands are the same, but I’m pretty sure there is a lot more that can be done with the Pulse 2. If no one has asked, I’ll put in a support request and see if we can get something, as that would make things a lot easier obviously :slight_smile:
Thanks for your work so far!

Hi,

At the moment the primary problem is that the @atmurray’s aiopulse module doesn’t find the Pulse hub v2. Hub finding is implemented in aiopulse using a UDP broadcast. In principle this should work as running a UDP scan on my LAN finds the hub v2 with a port open. aiopulse has a UDP port pre-specified which is not the right port for v2 hubs, however changing this to the right port doesn’t get it working. I am new to python so I can’t easily debug the issue, and currently am unsure if the problem is the UDP broadcast itself, or the command being sent to connect to the hub. Acmeda’s software doesn’t use UDP broadcasts, it just does a TCP scan on the TCP port open on the hub. This seems to work pretty reliably.

In updating aiopulse, it would make sense to get UDP broadcast working for v2 hubs and then have the TCP port scan as a fall back method. UDP broadcasts can be problematic sometimes as I understand it. I am trying to work on the former for now.

I’ve been looking a bit more just now… Looking at the aiopulse code, it appears very different to even the Serial Protocol doc linked above. Lots of bytes.fromhex("03000003") type constants etc. I’m looking at the electron code you posted, and manually typing stuff into a telnet session and (slowly) starting to figure some stuff out (listing motors, getting motor names etc - still looking how to get the battery information). I’m also thinking an evilscan implementation may not be a good idea for some installs (including mine), as it may need to cover a lot of IP addresses (I run a /8 network, so that would be over 16 million IP addresses). So we may need to look at a UI widget to allow users to enter the IP of their hub(s). (I have not yet tried UDP broadcast messages.) Given the current implementation (from the docs) does not handle IP address changes, it must just be storing the IP internally anyway (just a guess, not yet looked at the code).
I don’t know the HA code, but I do know Python and networking pretty well. There are other things we can also try for a network scan (including doing things with scapy to do a TCP broadcast scan - but again I think just entering an IP address/hostname would better and less prone to errors).
So in short, I think we are going to need to have an entirely new implementation for the v2 Pulse - but I may just be missing something. I’ll keep you updated as I figure out more.

I have started putting up some notes on the API here. There is a lot more that’s simple to figure out using the LinQ tool, but this is what I have so far. It’s a wiki page to make it easy to edit: https://github.com/sillyfrog/aiopulse2/wiki/API
I have also contacted Automate to see if we can get some more info on the v2 API.