Panel HMI para Protolink sobre Windows Forms (.NET + C#), utilizando Modbus TCP y la librería EasyModbus.
Este proyecto sirve como cliente HMI ligero para:
- Visualizar entradas analógicas (mA) con gráfica en tiempo real.
- Ver el estado de entradas digitales (DI).
- Leer y escribir salidas a relevador (DO / coils).
- Sentar las bases para futuras configuraciones por holding registers y expansión de I/O.
- Windows 10/11
- .NET Framework 4.8 o .NET 6 Windows (WinForms)
- Visual Studio (o IDE compatible)
- Acceso a un dispositivo Protolink con firmware Modbus TCP
Librería Modbus:
Install-Package EasyModbusTCP
using EasyModbus;
El proyecto está centrado en un FormMain (WinForms) que contiene:
-
txtIp, txtPort
-
btnConectar, btnDisconnect
-
lblEstadoConexion
-
Labels de valor:
-
lblCh1Value (AI1 en mA)
-
lblCh2Value (AI2 en mA)
-
chartCh1
-
chartCh2
- chkDi1…chkDi4 (solo visuales, Enabled = false)
-
chkR1…chkR4 (escritura de coils)
-
Escritura protegida con verificación de conexión y flag interno para evitar rebotes.
-
tmrPoll (típicamente 500 ms)
-
Encargado de disparar la lectura periódica de Modbus.
Los offsets de registros se definen en una clase estática tipo:
static class ProtolinkMap
{
// --- Input Registers (3x) ---
public const int INREG_AI1_RAW = 0; // 30001 (2 regs float)
public const int INREG_AI1_MA = 2; // 30003 (2 regs float)
public const int INREG_AI1_SCALED = 4; // 30005 (1 reg)
public const int INREG_AI2_RAW = 10; // 30011 (2 regs float)
public const int INREG_AI2_MA = 12; // 30013 (2 regs float)
public const int INREG_AI2_SCALED = 14; // 30015 (1 reg)
public const int INREG_BASCULA1 = 20; // 30021 (2 regs float)
public const int INREG_BASCULA2 = 22; // 30023 (2 regs float)
// --- Discrete Inputs (1x) ---
public const int DI_START = 0; // 10001..10004 → DI1..DI4
// --- Coils (0x) ---
public const int DO_START = 0; // 00001..00004 → R1..R4
// --- Holding Registers (4x) ---
// Reservado para expansión futura de configuraciones.
}EasyModbus utiliza direccionamiento 0-based: 0 → 30001, 1 → 30002, etc. El mapa está alineado con los #define originales del firmware Protolink.
private ModbusClient _modbus;
private void btnConectar_Click(object sender, EventArgs e)
{
string ip = txtIp.Text.Trim();
int port = int.Parse(txtPort.Text.Trim());
_modbus = new ModbusClient(ip, port);
// _modbus.UnitIdentifier = 1; // si el slave ID es distinto
_modbus.Connect();
SetConnectionState(_modbus.Connected);
}Desconexión segura:
private void btnDisconnect_Click(object sender, EventArgs e)
{
tmrPoll.Stop();
try
{
if (_modbus != null && _modbus.Connected)
_modbus.Disconnect();
}
finally
{
_modbus = null;
SetConnectionState(false);
}
}-
Actualiza lblEstadoConexion (texto / color).
-
Inicia/detiene tmrPoll.
-
Limpia los valores de UI.
-
btnConectar, btnDisconnect
-
chkR1…chkR4 (solo se pueden usar con conexión activa).
private bool _updatingFromPoll = false;
private void tmrPoll_Tick(object sender, EventArgs e)
{
if (_modbus == null || !_modbus.Connected)
return;
try
{
_updatingFromPoll = true;
UpdateAnalogInputs();
UpdateDigitalInputs();
UpdateRelayOutputs();
}
catch (Exception ex)
{
// Manejo básico de error: log / aviso y desconexión
Console.WriteLine("Error en poll: " + ex.Message);
SetConnectionState(false);
}
finally
{
_updatingFromPoll = false;
}
}Helper para float Modbus (HighWord–LowWord)
El firmware Protolink envía floats como HighWord–LowWord, así que se utiliza:
private float ReadFloatInputRegister(int startAddress)
{
int[] regs = _modbus.ReadInputRegisters(startAddress, 2);
return ModbusClient.ConvertRegistersToFloat(
regs, ModbusClient.RegisterOrder.HighLow);
}Actualizar UI y charts
private void UpdateAnalogInputs()
{
float ch1_mA = ReadFloatInputRegister(ProtolinkMap.INREG_AI1_MA);
float ch2_mA = ReadFloatInputRegister(ProtolinkMap.INREG_AI2_MA);
lblCh1Value.Text = ch1_mA.ToString("0.00") + " mA";
lblCh2Value.Text = ch2_mA.ToString("0.00") + " mA";
UpdateCharts(ch1_mA, ch2_mA);
}UpdateCharts agrega puntos a chartCh1 y chartCh2 manteniendo una ventana de N muestras:
private readonly int _maxChartPoints = 120; // p.ej. ~1 min a 500 ms
private void UpdateCharts(float ch1_mA, float ch2_mA)
{
DateTime now = DateTime.Now;
AppendChartPoint(chartCh1, now, ch1_mA);
AppendChartPoint(chartCh2, now, ch2_mA);
}
private void AppendChartPoint(Chart chart, DateTime time, double value)
{
if (chart.Series.Count == 0)
return;
Series s = chart.Series[0];
s.Points.AddXY(time.ToOADate(), value);
while (s.Points.Count > _maxChartPoints)
s.Points.RemoveAt(0);
if (s.Points.Count > 1)
{
double minX = s.Points[0].XValue;
double maxX = s.Points[^1].XValue;
var area = chart.ChartAreas[0];
area.AxisX.Minimum = minX;
area.AxisX.Maximum = maxX;
}
chart.Invalidate();
}Las entradas digitales se mapean a Discrete Inputs (1x) a partir de DI_START.
private void UpdateDigitalInputs()
{
bool[] di = _modbus.ReadDiscreteInputs(ProtolinkMap.DI_START, 4);
chkDi1.Checked = di.Length > 0 && di[0];
chkDi2.Checked = di.Length > 1 && di[1];
chkDi3.Checked = di.Length > 2 && di[2];
chkDi4.Checked = di.Length > 3 && di[3];
}Los checkboxes chkDiX están en modo solo lectura visual (Enabled = false).
Las salidas se manejan como Coils (0x) a partir de DO_START.
Lectura del estado real (por si hay otros masters Modbus)
private void UpdateRelayOutputs()
{
bool[] coils = _modbus.ReadCoils(ProtolinkMap.DO_START, 4);
chkR1.Checked = coils.Length > 0 && coils[0];
chkR2.Checked = coils.Length > 1 && coils[1];
chkR3.Checked = coils.Length > 2 && coils[2];
chkR4.Checked = coils.Length > 3 && coils[3];
}Escritura desde la UI (click en los checkboxes)
private void chkRelay_CheckedChanged(object sender, EventArgs e)
{
// Evitar escrituras cuando estamos actualizando desde el polling
if (_updatingFromPoll)
return;
CheckBox chk = (CheckBox)sender;
// Si no hay conexión, revertimos el cambio visual y salimos en silencio
if (_modbus == null || !_modbus.Connected)
{
_updatingFromPoll = true;
chk.Checked = !chk.Checked;
_updatingFromPoll = false;
return;
}
try
{
int coilIndex = 0;
if (chk == chkR1) coilIndex = 0;
else if (chk == chkR2) coilIndex = 1;
else if (chk == chkR3) coilIndex = 2;
else if (chk == chkR4) coilIndex = 3;
bool value = chk.Checked;
_modbus.WriteSingleCoil(ProtolinkMap.DO_START + coilIndex, value);
}
catch (Exception ex)
{
MessageBox.Show(
"Error al escribir relé: " + ex.Message,
"Escritura Modbus",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
}Los checkboxes chkR1…chkR4 solo están habilitados cuando SetConnectionState(true).
| Nombre | Offset (0-based) | Dirección Modbus | Descripción | Tipo |
|---|---|---|---|---|
INREG_AI1_RAW |
0 | 30001–30002 | Valor RAW AI1 (float) | float |
INREG_AI1_MA |
2 | 30003–30004 | Corriente AI1 en mA (float) | float |
INREG_AI1_SCALED |
4 | 30005 | Valor escalado AI1 (unidad proceso) | int / futuro float |
INREG_AI2_RAW |
10 | 30011–30012 | Valor RAW AI2 (float) | float |
INREG_AI2_MA |
12 | 30013–30014 | Corriente AI2 en mA (float) | float |
INREG_AI2_SCALED |
14 | 30015 | Valor escalado AI2 | int / futuro float |
INREG_BASCULA1 |
20 | 30021–30022 | Lectura báscula 1 | float |
INREG_BASCULA2 |
22 | 30023–30024 | Lectura báscula 2 | float |
Todos los float se leen como 2 registros y se convierten con ModbusClient.ConvertRegistersToFloat(regs, RegisterOrder.HighLow).
| Señal | Tipo | Offset (0-based) | Dirección típica |
|---|---|---|---|
| DI1 | Discrete Input | DI_START + 0 |
10001 |
| DI2 | Discrete Input | DI_START + 1 |
10002 |
| DI3 | Discrete Input | DI_START + 2 |
10003 |
| DI4 | Discrete Input | DI_START + 3 |
10004 |
| Señal | Tipo | Offset (0-based) | Dirección típica |
|---|---|---|---|
| R1 | Coil | DO_START + 0 |
00001 |
| R2 | Coil | DO_START + 1 |
00002 |
| R3 | Coil | DO_START + 2 |
00003 |
| R4 | Coil | DO_START + 3 |
00004 |
La estructura del proyecto está pensada para crecer sin romper nada.
Pendiente de integrar (próximos sprints). Se propone declarar en ProtolinkMap algo como:
// --- Holding Registers (4x) - Configuración ---
public const int HOLD_CFG_BASE = 0; // 40001+
public const int HOLD_CFG_MODE_OP = HOLD_CFG_BASE + 0; // modo operación
public const int HOLD_CFG_HEARTBEAT = HOLD_CFG_BASE + 1; // heartbeat, s
public const int HOLD_CFG_FILTER_AI1 = HOLD_CFG_BASE + 2; // filtro AI1
public const int HOLD_CFG_FILTER_AI2 = HOLD_CFG_BASE + 3; // filtro AI2
// ... seguir en orden para nuevos parámetros
En el HMI se puede agregar una sección de “Configuración” con:
Lectura total (ReadHoldingRegisters) a un bloque continuo.
Escritura controlada (WriteMultipleRegisters o WriteSingleRegister) con confirmación de usuario.
Si se amplía el hardware:
- Entradas digitales:
Extender DI_START manteniendo el orden (DI5 = DI_START + 4, etc.)
UI: añadir chkDi5… o migrar la sección DI a un DataGridView.
- Salidas (coils):
Extender DO_START (R5 = DO_START + 4, etc.).
UI: añadir nuevos checkboxes o filas en una tabla de salidas.
La lógica de lectura/escritura es fácilmente escalable modificando:
-
Cantidad de bits en las llamadas ReadDiscreteInputs / ReadCoils.
-
El switch o mapping en chkRelay_CheckedChanged.
-
El HMI usa EasyModbus como cliente Modbus TCP para hablar con Protolink.
-
Se implementa una desconexión segura y un polling periódico de entradas/salidas.
-Las análogas se leen como float en formato HighWord–LowWord y se grafican en tiempo real.
-
Las entradas digitales se muestran como indicadores, y las salidas pueden controlarse desde la UI, respetando el estado del bus.
-
El diseño del mapa (ProtolinkMap) y de la UI deja espacio reservado para:
- Configuración avanzada por holding registers. - Expansión de más entradas y salidas manteniendo el orden actual.
Cuando haya nuevos registros o módulos (config, diagnóstico extra, más AI/DI/DO), basta con:
1.- Declararlos en ProtolinkMap.
2.- Amarrarlos a nuevos controles en el formulario.
3.- Incluirlos en el ciclo de polling / escritura donde corresponda.