HaddingtonDynamics / Dexter

GNU General Public License v3.0
363 stars 84 forks source link

Add modbus server support to Dexter #84

Open JamesNewton opened 4 years ago

JamesNewton commented 4 years ago

Some support for modbus via the Job Engine or PC versions of DDE has been documented here: https://github.com/HaddingtonDynamics/Dexter/wiki/Dexter-ModBus This allows jobs to act as modbus clients and make requests of other modbus devices which include a server function. It is also possible to start a job, start a modbus server via the library in that job, and then act as a modbus server as long as the job is running.

It may be more reliable and simpler to include a modbus server function as a part of the node server which already provides a websocket proxy and web server: https://github.com/HaddingtonDynamics/Dexter/wiki/nodejs-webserver The primary advantage is that this is generally always running as long as the robot is on, and since it typically isn't changed, it can (hopefully) be more reliable.

The expected use case is that a modbus device could send a request to Dexter and know that it would be recieved. The question is: What to do with that request?

Dexter doesn't have relays or coils or even individual actuators other than it's joints. However, it can run jobs. One idea is to map setCoil requests to starting jobs. E.g. setCoil 1 on would start /srv/samba/share/dde_app/modbus1.dde. The same request with "off" instead of "on" would kill that job. ReadCoil 1 would return the status of the job: true for running, false if not running.

While the job is running, it could output data back to the modbus system via a special version of the "out" command and so set holding register values for other modbus devices to read.

Of course, the job could also send modbus commands to other devices directly.

We can imagine a new Dexter owner who wants to integrate the arm into an existing assembly line. They might use DDE's record panel to put the robot into follow mode, record a simple movement that picks up a box and sets it down out of the way, and then save that job to the robot as "/srv/samba/share/dde_apps/modbus1.dde" and then program the line to send a setCoil 1 true when the box is ready. Done, and basically zero programming.

But then, maybe they need to send back a message to the line to tell it that it can send Dexter the next box. This code can be added to the job with some programming as per the existing example, or with a single "out" command a register can be set and the line can be programmed to check that register on a regular interval. (It is probably possible to make the sending of a simple modbus command from a job much easier, but that would have to be done in DDE / Job Engine)

To allow Dexter to always response to ModBus commands from another source including the basic functionality of setting and getting registers, and starting a job, then reading back values we can add some code to the built in web server. To use this, you must first SSH into Dexter and then

cd /srv/samba/share
node install modbus-serial

and then add the following to /srv/samba/share/www/httpd.js

// ModBus client server
const ModbusRTU = require("modbus-serial");
var modbus_reg = []

function modbus_startjob(job_name) {
    console.log(job_name)
    let jobfile = DDE_APPS_FOLDER + job_name + ".dde"
    let job_process = get_job_name_to_process(job_name)
    if(!job_process){
        console.log("spawning " + jobfile)
        //https://nodejs.org/api/child_process.html
        //https://blog.cloudboost.io/node-js-child-process-spawn-178eaaf8e1f9
        //a jobfile than ends in "/keep_alive" is handled specially in core/index.js
        job_process = spawn('node',
        ["core define_and_start_job " + jobfile],   
        {cwd: DDE_INSTALL_FOLDER, shell: true}
        )
        set_job_name_to_process(job_name, job_process)
        console.log("Spawned " + DDE_APPS_FOLDER + job_name + ".dde as process id " + job_process)
        job_process.stdout.on('data', function(data) {
        console.log("\n\n" + job_name + ">'" + data + "'\n")
        let data_str = data.toString()
        if (data_str.substr(0,7) == "modbus:") { //expecting 'modbus: 4, 123' or something like that
            [addr, value] = data_str.substr(7).split(",").map(x => parseInt(x) || 0)
            modbus_reg[addr] = value
        //TODO: Change this to something that allows multiple values to be set in one out.
            }
        })

        job_process.stderr.on('data', function(data) {
        console.log("\n\n" + job_name + "!>'" + data + "'\n")
        //remove_job_name_to_process(job_name) //error doesn't mean end.
        })
        job_process.on('close', function(code) {
        console.log("\n\nJob: " + job_name + ".dde closed with code: " + code)
        //if(code !== 0){  } //who do we tell if a job crashed?
        remove_job_name_to_process(job_name)
        })
        }
    else {
        console.log("\n" + job_name + " already running as process " + job_process)
        } //finished with !job_process
    }

var vector = {
    //TODO: Figure out what to return as inputs.
    // Possible: Values from a file? 
    // e.g. modbus.json has an array where jobs can store data to be read out here.
    // maybe that is the modbus_reg array as a json file?
    getInputRegister: function(addr) { //doesn't get triggered by QModMaster for some reason.
    //This does work mbpoll -1 -p 8502 -r 2 -t 3 192.168.0.142 
        console.log("read input", addr)
        return addr; //just sample data
        },
    getMultipleInputRegisters: function(startAddr, length) {
        console.log("read inputs from", startAddr, "for", length); 
        var values = [];
        for (var i = startAddr; i < length; i++) {
            values[i] = startAddr + i; //just sample return data
            }
        return values;
        },
    getHoldingRegister: function(addr) {
        let value = modbus_reg[addr] || 0
        console.log("read register", addr, "is", value)
        return value 
        },
    getMultipleHoldingRegisters: function(startAddr, length) {
        console.log("read registers from", startAddr, "for", length); 
        let values = []
        for (var i = 0; i < length; i++) {
            values[i] = modbus_reg[i] || 0
            }
        return values
        },
    setRegister: function(addr, value) { 
        console.log("set register", addr, "to", value) 
        modbus_reg[addr] = value
        return
        },
    getCoil: function(addr) { //return 0 or 1 only.
        let value = ((addr % 2) === 0) //just sample return data
        console.log("read coil", addr, "is", value)
        return value 
        //TODO Return the status of the job modbuscoil<addr>.dde
        // e.g. 1 if it's running, 0 if it's not.
        },
    setCoil: function(addr, value) { //gets true or false as a value.
        console.log("set coil", addr, " ", value)
    if (value) { modbus_startjob("modbus" + addr) }
    else { console.log("stop") }
        //TODO Start or kill job modbuscoil<addr>.dde depending on <value>
        // Maybe pass in with modbus_reg as a user_data? or they can access the file?
        return; 
        },
    readDeviceIdentification: function(addr) {
        return {
            0x00: "HaddingtonDynamics",
            0x01: "Dexter",
            0x02: "1.1",
            0x05: "HDI",
            0x97: "MyExtendedObject1",
            0xAB: "MyExtendedObject2"
        };
    }
};

// set the server to answer for modbus requests
console.log("ModbusTCP listening on modbus://0.0.0.0:8502");
var serverTCP = new ModbusRTU.ServerTCP(vector, { host: "0.0.0.0", port: 8502, debug: true, unitID: 1 });

serverTCP.on("initialized", function() {
    console.log("initialized");
});

serverTCP.on("socketError", function(err) {
    console.error(err);
    serverTCP.close(closed);
});

function closed() {
    console.log("server closed");
}

The following sample job sets register 1 to 123 (and could, of course, move the robot or do whatever) and is activated when Dexter is told to set coil 1 to true. `/srv/samba/share/dde_apps/modbus1.dde


function modbus_setreg(reg, value) {
    console.log("modbus:", reg, ",", value)
    }

new Job({name: "modbus1", 
    do_list: [
        //Robot.out("modbus: 1, 123")
    function() {modbus_setreg(1, 123)}
        ]
    })

Or see the complete file here:
https://github.com/HaddingtonDynamics/Dexter/blob/Stable_Conedrive/Firmware/www/httpd.js

Notes: https://sourceforge.net/projects/qmodmaster/ is a wonderful tool for Windows, GUI, easy to use, clear interface. Hit the CAT5 icon, enter Dexters IP and port 8502, then select unit ID 1, select the address, length, and on you go.

https://github.com/epsilonrt/mbpoll provides modbus client testing tool for Ubuntu. e.g. mbpoll -1 -p 8502 -t 0 192.168.1.142 connects to the Dexter at .142 on the local network, via port 8502, and reads coil 0. To set a coil, add a 0 or 1 at the end of the command. To read coil 1, the option is -r 2 unless you include the -0 option, then it's -r 1. e.g. to read coil 2, either of these provide the same response:

>mbpoll -0 -1 -r 2 -t 0 -p 8502 192.168.1.142
>mbpoll -1 -r 3 -t 0 -p 8502 192.168.1.142
mbpoll 1.4-12 - FieldTalk(tm) Modbus(R) Master Simulator
Copyright © 2015-2019 Pascal JEAN, https://github.com/epsilonrt/mbpoll
This program comes with ABSOLUTELY NO WARRANTY.
This is free software, and you are welcome to redistribute it
under certain conditions; type 'mbpoll -w' for details.

Protocol configuration: Modbus TCP
Slave configuration...: address = [1]
                        start reference = 3, count = 1
Communication.........: 192.168.1.142, port 8502, t/o 1.00 s, poll rate 1000 ms
Data type.............: discrete output (coil)

-- Polling slave 1...
[3]:    1

To set coil 1, use mbpoll -0 -1 -p 8502 -r 1 -t 0 192.168.1.142 1

JamesNewton commented 4 years ago

This is being made part of the standard software in the "Stable_Conedrive" branch. Once that is released, we can close this issue.