Skip to main content
Version: 4.2 (beta)

Modbus TCP Server / Tecnoalarm panel gateway

This tutorial will guide you in setting up HSYCO as a gateway to enable communication between Modbus TCP and one of the security panels integrated into HSYCO. HSYCO will act as a Modbus TCP server.

To access HSYCO Manager open a browser and type the URL:

https://192.168.1.50/hsycoserver/manager

note

The IP address and the URLKey written above are the default ones

Add a security control panel I/O Server​

Follow these steps of this tutorial:

Add a Tecnoalarm I/O Server

Add a Modbus Tcp server I/O Server​

1. Go back to the Manager splash screen

2. Click on Settings.

3. Add a new I/O server.

4. Click the type input field.

5. Type "mod"

6. Select Modbus TCP Server.

7. Select the name input field.

8. Assign a unique name to the I/O server, for example "mserver"

9. Click the port input field.

10. Type "502", the default Modbus port.

11. Click "Confirm"

12. Click on the Options icon.

13. Add a new option.

14. Click the option field.

15. Select "eventslog".

16. Click the value input field.

17. Select "true".

18. Click "Confirm".

19. Close the Options popup.

20. Click "Save Apply & restart".

21. Confirm by clicking the Save button.

Check the connection status​

1. Let’s now check the connection status of the I/O Servers we just added. Go back to the Manager splash screen and click on Status Browser.

2. Click the "Filter" field to search for the connection datapoint.

3. Type " tecno.conn"

4. If the datapoint ‘tecno.connection’ is equal to ‘online,’ this means that the Tecnoalarm I/O server is successfully connected to HSYCO.

EVENTS scripting​

1. From the Manager splash screen select Code Editor.

2. Click "New Events File"

3. Give a name to your script, for example: "gateway.txt"

4. Click "New File"

5. Now it's time to write some lines of code.

For further information about the logic of EVENTS programming, read EVENTS programming quickstart

Let's start with some simple functions:

#copy the status of a zone of the security panel to a Modbus register (Holding Register)
io tecno.zone.1.alarm = 1 : io mserver.1.1.hr = "ushort:"1
io tecno.zone.1.alarm = 0 : io mserver.1.1.hr = "ushort:"0

#copy the status of a zone of the security panel to a Modbus register (Coil)
io tecno.zone.2.alarm = 1 : io mserver.1.10.co = "bit:"1
io tecno.zone.2.alarm = 0 : io mserver.1.10.co = "bit:"0

#when a Modbus register is set to 1/0, arm/disarm a security panel program
io mserver.1.20.hr = 1 : io tecno.program.1 = arm
io mserver.1.20.hr = 0 : io tecno.program.1 = disarm

Click ‘Save’ in the upper right corner to activate this script.

The complete list of all the datapoints generated by an I/O Server is available in the Integrations section.

You can use Javascript as well. For example, this code automatically keeps the status of a Tecnoalarm panel aligned with a series of registers in a Modbus TCP Server, and allows sending commands to the panel by writing to some of these registers.

The gateway works as follows:

  • when the panel goes online, after 15 seconds the gateway reads all programs, zones and general statuses and writes the complete Modbus table, then sets register 196 to 1
  • from that moment, every change of the panel updates only the related register
  • when the Modbus TCP server restarts, the table is written again
  • writing 0 or 3 to a command register sends the command to the panel

Create a single EVENTS file with this code:

#********************************************************
# ************** GATEWAY TECNOALARM MODBUS **************
#********************************************************
#
# Single EVENTS file: replaces both previous gateway files (status + commands).
#
# Requirements:
# - If you change the I/O server ids (gwPanel / gwServer), change them also in
# the rules at the end of this file.
#
# Modbus map (holding registers):
# 1..64 program status (2 registers per program, LSB first)
# 65..127 program commands (odd registers): 0 = disarm, 3 = arm
# 196 connection status: 1 = gateway ready, 0 = not ready
# 198..1220 zone status (zone n = 196 + 2n)
# 1223..2245 zone commands (zone n = 1221 + 2n): 0 = include, 3 = exclude
# 3001..3009 panel general status

init : {

// ---------- CONFIGURATION ----------
var unitid = "1"; // Modbus unit id
var gwPanel = "tecno"; // Tecnoalarm I/O server id
var gwServer = "mserver"; // Modbus TCP server I/O server id
var gwMaxPrograms = 32; // program status/command tables size
var gwMaxZones = 512; // zone status/command tables size
var gwVerbose = true; // true = log panel events, register changes and Modbus commands
var gwDebug = false; // true = also log register bits and every write of the full sync

// ---------- TABLES ----------
// program sub state, bits 0..3 (Table 1.1)
var gwProgramStates = { off: 0, preout: 1, out: 2, armed: 3, armedpartial: 4, outpartial: 5, endpartial: 6 };
var gwProgramAttrs = { status: 1, prealarm: 1, alarm: 1, alarmmemory: 1 };
var gwZoneAttrs = { excluded: 1, cablecutstatus: 1, cablecutalarm: 1, lowbattery: 1, noradio: 1, alarm: 1, radiolearned: 1, active: 1, alarmmemory: 1 };
var gwSystemRegs = { fault: 3001, lowbattery: 3002, nopower: 3003, tamper: 3004, radiofault: 3005, techzone: 3006, normal: 3007, maintenance: 3008, panic: 3009 };

// ---------- STATE ----------
var gwReady = false; // false until the full status table has been written
var gwSyncing = false; // true while gwSync is writing the whole table
var gwWriteCount = 0; // registers written (used for the sync summary)
var gwRegCache = {}; // last value written to each register
var gwZoneMemory = {}; // alarm memory latched by the gateway (zone in alarm while active)
var gwPending = {}; // commands sent to the panel and not confirmed yet ("p1", "z5" -> state, time)
var gwPendingMs = 15000; // a command not confirmed within this time is forgotten
var gwEventName = null; // datapoint of the event being processed...
var gwEventValue = null; // ...and its new value

function gwLog(msg) {
messageLog("GATEWAY TM - " + msg);
}

function gwVerboseLog(msg) {
if (gwVerbose || gwDebug) gwLog(msg);
}

function gwDebugLog(msg) {
if (gwDebug) gwLog(msg);
}

// read a panel datapoint; the event being processed wins over ioGet
function gwRead(dp) {
var full = gwPanel + "." + dp;
if (full == gwEventName) return gwEventValue;
var v = ioGet(full);
return (v == null) ? null : String(v);
}

function gwFlag(dp) {
return (gwRead(dp) == "1") ? 1 : 0;
}

// write a whole 16 bit register, only if its value changed
function gwWrite(reg, word, desc) {
if (gwRegCache[reg] === word) return;
var bits = "";
for (var i = 15; i >= 0; i--) bits += (word >> i) & 1;
ioSet(gwServer + "." + unitid + "." + reg + ".hr", "bits:" + bits);
var old = gwRegCache[reg];
gwRegCache[reg] = word;
gwWriteCount++;
var msg = "MSERVER WRITE reg " + reg + " = " + word
+ (old !== undefined ? " (was " + old + ")" : "")
+ (desc ? " - " + desc : "")
+ (gwDebug ? " [" + bits + "]" : "");
if (gwSyncing) gwDebugLog(msg); else gwVerboseLog(msg);
}

// keep a command register aligned with the panel, so the next command is always a change.
// Writes made by EVENTS do not generate events, so they never come back to gwCommand
function gwWriteCommand(reg, word, desc) {
gwWrite(reg, word, desc);
}

// ---------- COMMANDS IN PROGRESS ----------
// the state the panel will have when the last command sent is executed, or null.
// Used for the commands and for the command registers, so that a command sent before
// the panel has executed the previous one is not lost
function gwPendingState(key) {
var c = gwPending[key];
if (c === undefined) return null;
if (new Date().getTime() - c.time > gwPendingMs) {
delete gwPending[key];
gwLog(key + " - command not confirmed by the panel");
return null;
}
return c.state;
}

function gwPendingSet(key, state) {
gwPending[key] = { state: state, time: new Date().getTime() };
}

// ---------- PROGRAMS ----------
// bits 0..3 sub state, 4 pre alarm, 5 alarm, 6 alarm memory
function gwUpdateProgram(p) {
if (p < 1 || p > gwMaxPrograms) return false;
var pre = "program." + p + ".";
var status = gwRead(pre + "status");
if (status == null) return false;
var state = gwProgramStates[status];
if (state === undefined) {
gwLog("program " + p + " unknown status: " + status);
state = 0;
}
var prealarm = gwFlag(pre + "prealarm");
var alarm = gwFlag(pre + "alarm");
var memory = gwFlag(pre + "alarmmemory");
var word = state | (prealarm << 4) | (alarm << 5) | (memory << 6);
var flags = [status];
if (prealarm) flags.push("prealarm");
if (alarm) flags.push("alarm");
if (memory) flags.push("alarm memory");
gwWrite(2 * p - 1, word, "program " + p + " status: " + flags.join(", "));
gwWrite(2 * p, 0, "program " + p + " status (high word)");
// arm is confirmed by any status other than off (exit time included)
var pending = gwPendingState("p" + p);
if (pending != null && (pending == "off") == (status == "off")) {
delete gwPending["p" + p];
gwVerboseLog("program " + p + " - command confirmed by the panel (" + status + ")");
pending = null;
}
var cmd = ((pending != null ? pending : status) == "off") ? 0 : 3;
gwWriteCommand(2 * p + 63, cmd, "program " + p + " command register aligned: " + (cmd == 3 ? "armed" : "disarmed") + (pending != null ? " (command pending)" : ""));
return true;
}

// ---------- ZONES ----------
// bits 0 exclusion, 2 cable cut, 3 cable cut alarm, 4 battery, 5 no radio,
// 6 open/close (open = 0, close = 1), 7 learned, 8 active, 11 alarm memory
function gwUpdateZone(z) {
if (z < 1 || z > gwMaxZones) return false;
var pre = "zone." + z + ".";
var alarm = gwRead(pre + "alarm");
if (alarm == null) return false;
var excluded = gwFlag(pre + "excluded");
var cablecut = gwFlag(pre + "cablecutstatus");
var cablecutalarm = gwFlag(pre + "cablecutalarm");
var lowbattery = gwFlag(pre + "lowbattery");
var noradio = gwFlag(pre + "noradio");
var learned = gwFlag(pre + "radiolearned");
var active = gwFlag(pre + "active");
var memory = (gwFlag(pre + "alarmmemory") || gwZoneMemory[z]) ? 1 : 0;
var word = excluded
| (cablecut << 2)
| (cablecutalarm << 3)
| (lowbattery << 4)
| (noradio << 5)
| ((alarm == "1" ? 0 : 1) << 6)
| (learned << 7)
| (active << 8)
| (memory << 11);
var flags = [alarm == "1" ? "open" : "closed"];
if (excluded) flags.push("excluded");
if (cablecut) flags.push("cable cut");
if (cablecutalarm) flags.push("cable cut alarm");
if (lowbattery) flags.push("low battery");
if (noradio) flags.push("no radio");
if (learned) flags.push("radio learned");
if (active) flags.push("active");
if (memory) flags.push("alarm memory");
gwWrite(196 + 2 * z, word, "zone " + z + " status: " + flags.join(", "));
var pending = gwPendingState("z" + z);
if (pending != null && pending == String(excluded)) {
delete gwPending["z" + z];
gwVerboseLog("zone " + z + " - command confirmed by the panel (" + (excluded ? "excluded" : "included") + ")");
pending = null;
}
var cmd = ((pending != null ? pending : String(excluded)) == "1") ? 3 : 0;
gwWriteCommand(1221 + 2 * z, cmd, "zone " + z + " command register aligned: " + (cmd == 3 ? "excluded" : "included") + (pending != null ? " (command pending)" : ""));
return true;
}

// ---------- PANEL GENERAL STATUS ----------
function gwUpdateSystem(key) {
var reg = gwSystemRegs[key];
if (reg === undefined) return;
var v = gwRead("system." + key);
if (v == null) return;
gwWrite(reg, (v == "1") ? 1 : 0, "system " + key + ": " + v);
}

// ---------- FULL SYNC ----------
// reads the whole panel status and writes the complete Modbus table
function gwSync() {
var t0 = new Date().getTime();
var programs = 0, zones = 0;
gwLog("SYNC - writing the full Modbus table");
// events arriving from now on are applied too, so no change can be lost
gwReady = true;
gwSyncing = true;
gwWriteCount = 0;
try {
for (var p = 1; p <= gwMaxPrograms; p++) {
if (gwUpdateProgram(p)) programs++;
}
for (var z = 1; z <= gwMaxZones; z++) {
if (gwFlag("zone." + z + ".alarm") && gwFlag("zone." + z + ".active")) gwZoneMemory[z] = true;
if (gwUpdateZone(z)) zones++;
}
for (var key in gwSystemRegs) gwUpdateSystem(key);
} finally {
gwSyncing = false;
}
gwWrite(196, 1, "connection: gateway ready");
gwLog("READY - programs: " + programs + " / zones: " + zones + " / registers written: " + gwWriteCount + " / " + (new Date().getTime() - t0) + " ms");
}

function gwOffline() {
gwReady = false;
gwPending = {};
gwWrite(196, 0, "connection: gateway not ready");
gwLog("TECNOALARM OFFLINE OR GATEWAY NOT READY");
}

// command registers generate events only while subscribed; the subscriptions are
// lost when the Modbus server restarts and when the EVENTS files are reloaded
function gwSubscribe() {
var r, count = 0;
for (r = 65; r <= 63 + 2 * gwMaxPrograms; r += 2) { ioSet(gwServer + "." + unitid + "." + r + ".hr", "subscribe:ushort"); count++; }
for (r = 1223; r <= 1221 + 2 * gwMaxZones; r += 2) { ioSet(gwServer + "." + unitid + "." + r + ".hr", "subscribe:ushort"); count++; }
gwLog("MSERVER - subscribed " + count + " command registers");
}

// the Modbus server (re)started: its registers are empty
function gwServerOnline() {
gwLog("MSERVER ONLINE - " + (gwReady ? "rewriting the full table" : "panel not ready yet"));
gwRegCache = {};
gwSubscribe();
if (gwReady) {
gwSync();
} else {
gwWrite(196, 0, "connection: gateway not ready");
}
}

// ---------- EVENTS FROM THE PANEL ----------
function gwPanelEvent(name, value) {
var parts = name.split(".");
gwEventName = name;
gwEventValue = value;
try {
if (parts.length == 3 && parts[1] == "system") {
if (gwSystemRegs[parts[2]] !== undefined) gwVerboseLog("TECNO EVENT " + name + " = " + value);
gwUpdateSystem(parts[2]);
} else if (parts.length == 4) {
var n = parseInt(parts[2], 10);
var attr = parts[3];
if (parts[1] == "program" && gwProgramAttrs[attr]) {
gwVerboseLog("TECNO EVENT " + name + " = " + value);
gwUpdateProgram(n);
} else if (parts[1] == "zone" && gwZoneAttrs[attr]) {
gwVerboseLog("TECNO EVENT " + name + " = " + value);
if (attr == "active") {
if (gwZoneMemory[n]) gwVerboseLog("zone " + n + " - gateway alarm memory cleared");
delete gwZoneMemory[n];
} else if (attr == "alarm" && value == "1" && gwFlag("zone." + n + ".active")) {
if (!gwZoneMemory[n]) gwVerboseLog("zone " + n + " - gateway alarm memory latched");
gwZoneMemory[n] = true;
}
gwUpdateZone(n);
}
}
gwDebugLog("event " + name + " = " + value);
} finally {
gwEventName = null;
gwEventValue = null;
}
}

// ---------- COMMANDS FROM MODBUS ----------
function gwCommand(name, value) {
var parts = name.split(".");
if (parts.length != 4 || parts[1] != unitid || parts[3] != "hr") return;
var reg = parseInt(parts[2], 10);
// HSYCO can run the event handlers in parallel and out of order: always use the last
// value written by the Modbus client, so that the last command wins
var last = ioGet(name);
if (last != null && String(last) != value) {
gwVerboseLog("MSERVER COMMAND reg " + reg + " = " + value + " (superseded by last value " + last + ")");
value = String(last);
} else {
gwVerboseLog("MSERVER COMMAND reg " + reg + " = " + value);
}
gwRegCache[reg] = parseInt(value, 10);
var isProgram = reg >= 65 && reg <= 63 + 2 * gwMaxPrograms && reg % 2 == 1;
var isZone = reg >= 1223 && reg <= 1221 + 2 * gwMaxZones && reg % 2 == 1;
if (value != "0" && value != "3") {
gwLog("register " + reg + ": invalid command value " + value);
// write back the value aligned with the panel (3 or 0)
if (gwReady && isProgram) gwUpdateProgram((reg - 63) / 2);
if (gwReady && isZone) gwUpdateZone((reg - 1221) / 2);
return;
}
if (!gwReady) {
gwLog("register " + reg + ": command ignored, gateway not ready");
return;
}
if (isProgram) {
var p = (reg - 63) / 2;
var status = gwRead("program." + p + ".status");
var pendingStatus = gwPendingState("p" + p);
if (pendingStatus != null) status = pendingStatus;
if (status == null) {
gwLog("T2 - program " + p + " does not exist");
} else if (value == "3" && status != "armed") {
ioSet(gwPanel + ".program." + p, "arm");
gwPendingSet("p" + p, "armed");
gwLog("T2 - program " + p + " arm (was " + status + ")");
} else if (value == "0" && status != "off") {
ioSet(gwPanel + ".program." + p, "disarm");
gwPendingSet("p" + p, "off");
gwLog("T2 - program " + p + " disarm (was " + status + ")");
} else {
gwVerboseLog("T2 - program " + p + " already " + status + (pendingStatus != null ? " (command pending)" : "") + ", nothing to do");
}
} else if (isZone) {
var z = (reg - 1221) / 2;
var excluded = gwRead("zone." + z + ".excluded");
var pendingExcluded = gwPendingState("z" + z);
if (excluded != null && pendingExcluded != null) excluded = pendingExcluded;
if (excluded == null) {
gwLog("T4 - zone " + z + " does not exist");
} else if (value == "3" && excluded != "1") {
ioSet(gwPanel + ".zone." + z, "disable");
gwPendingSet("z" + z, "1");
gwLog("T4 - zone " + z + " exclusion");
} else if (value == "0" && excluded == "1") {
ioSet(gwPanel + ".zone." + z, "enable");
gwPendingSet("z" + z, "0");
gwLog("T4 - zone " + z + " inclusion");
} else {
gwVerboseLog("T4 - zone " + z + " already " + (excluded == "1" ? "excluded" : "included") + (pendingExcluded != null ? " (command pending)" : "") + ", nothing to do");
}
} else {
gwVerboseLog("register " + reg + " is not a command register, ignored");
}
}

// EVENTS file reloaded while the Modbus server and the panel are already online
if (ioGet(gwServer + ".connection") == "online") gwSubscribe();
if (ioGet(gwPanel + ".connection") == "online") gwSync();
}


# for each event
function IOEvent(name, value) : {
var n = String(name);
if (n.indexOf(gwPanel + ".") == 0) {
// ignored until the full table has been written
if (gwReady) gwPanelEvent(n, String(value));
} else if (n.indexOf(gwServer + ".") == 0 && n != gwServer + ".connection") {
gwCommand(n, String(value));
}
}


# panel online: wait for the panel status to settle, then write the whole table
io tecno.connection = online : programtimer startgateway = set 15
io tecno.connection = offline : programtimer startgateway = clear

programtimer startgateway : {
gwSync();
}

io tecno.connection = offline : {
gwOffline();
}

# Modbus server (re)started: subscribe command registers and rewrite the table
io mserver.connection = online : {
gwServerOnline();
}

MODBUS tables​

Supported Modbus functions, Modbus TCP/IP

03: Read Holding Registers

06: Write Single Register

note

The register numbers in the tables below are Modbus protocol addresses, unit id 1. Clients that use the 1-based "4xxxx" holding register notation must add 40001: for example, register 196 is 40197 and register 1225 is 41226.

Program Status Table​

Modbus addressInfo
1Program status 1
3Program status 2
5Program status 3
7Program status 4
9Program status 5
11Program status 6
13Program status 7
15Program status 8
17Program status 9
19Program status 10
21Program status 11
23Program status 12
25Program status 13
27Program status 14
29Program status 15
31Program status 16
33Program status 17
35Program status 18
37Program status 19
39Program status 20
41Program status 21
43Program status 22
45Program status 23
47Program status 24
49Program status 25
51Program status 26
53Program status 27
55Program status 28
57Program status 29
59Program status 30
61Program status 31
63Program status 32

Possible Values:

bitDescriptionnote
0..3sub stateSee Table 1.1
4pre alarm
5alarm
6alarm memory
7..32not used

Table 1.1

ValueStatusTecnoalarm program status
0normal statusoff
1pre exitpreout
2exitout
3armedarmed
4armed partialarmedpartial
5partialoutpartial
6end of partializationendpartial

Program Commands Table​

Send a command to the Tecnoalarm panel by writing 0 or 3 in one of this registers

Modbus addressInfo
65Program command 1
67Program command 2
69Program command 3
71Program command 4
73Program command 5
75Program command 6
77Program command 7
79Program command 8
81Program command 9
83Program command 10
85Program command 11
87Program command 12
89Program command 13
91Program command 14
93Program command 15
95Program command 16
97Program command 17
99Program command 18
101Program command 19
103Program command 20
105Program command 21
107Program command 22
109Program command 23
111Program command 24
113Program command 25
115Program command 26
117Program command 27
119Program command 28
121Program command 29
123Program command 30
125Program command 31
127Program command 32

Possible Values:

valueDescription
0disarm program
3arm program

The gateway keeps these registers aligned with the panel: 3 when the program is not off (armed, partial or exit time), 0 when it is off. This way a command is always accepted, even after the program has been armed or disarmed from a keypad.

Connection Status​

Modbus addressInfo
1960 : HSYCO not connected with Tecnoalarm panel, or Modbus table not updated yet
1961 : HSYCO connected and Modbus table updated

Zone Status Table​

Modbus addressZone Status
198Zone status 1
200Zone status 2
202Zone status 3
......
......
1220Zone status 512

Possible Values:

bitDescriptionnote
0permanent exclusion
1
2cable cut
3alarm cable cut
4battery status
5no radio
6open/closeopen = 0, close = 1
7learned
8active1 = armed, 0 = disarmed
9
10
11alarm memory

Zone Commands Table​

Modbus addressZone Status
1223Zone command 1
1225Zone command 2
1227Zone command 3
......
......
2245Zone command 512

Possible Values:

valueDescription
0include zone
3exclude zone

The gateway keeps these registers aligned with the panel: 3 when the zone is excluded, 0 when it is included.

Panel General Status​

Modbus addressInfo
30011 : At least one fault in the panel
30010 : No faults
30021 : Low battery
30020 : No low battery
30031 : No AC power
30030 : AC power is present
30041 : General tamper alarm
30040 : No general tamper alarm
30051 : General radio module fault
30050 : No general radio module fault
30061 : Tech zones open
30060 : Tech zones not open
30071 : Normal status
30070 : Not in normal status
30081 : Maintenance mode active
30080 : Maintenance mode not active
30091 : Panic mode active
30090 : Panic mode not active