Read and write Cutler-Hammer D50 PLCs from Python. pyD50 also puts the CPU in
RUN or STOP, uploads and downloads the ladder program, and reads the CPU status.
It speaks the same serial protocol as Cutler-Hammer Windows GPC (WinGPC), and
its API follows pylogix: a PLC object
whose methods return Response objects with .TagName, .Value and .Status.
Warning! PLCs control industrial equipment. Writing values, changing the CPU mode or downloading a program to a PLC that is operating equipment should be done with great care and is at your own risk.
A D50 has an RS-232 programming port. pyD50 reaches it through a MOXA DE-311 (or any similar serial device server) on the network, or through a local serial port. The connection type is chosen from the address you give:
| Address | Connection |
|---|---|
PLC('100.100.100.254') |
MOXA in Real COM mode: data port 950, command port 966 |
PLC('100.100.100.254', port=950) |
the same, port given explicitly |
PLC('100.100.100.254', port=4001) or PLC('100.100.100.254:4001') |
MOXA in TCP Server mode (raw TCP, any port) |
PLC('COM3'), PLC('/dev/ttyUSB0') |
local serial port (needs pip install pyserial) |
- Real COM mode is the mode used when the MOXA Windows driver maps a virtual COM port. pyD50 speaks the driver's command channel itself, so no driver is needed. A Real COM port serves one client at a time: close any program that has the virtual COM port open (e.g. take WinGPC offline) first.
- In TCP Server mode the MOXA passes bytes straight through. The MOXA's own serial settings must then be 9600, 8 data bits, no parity, 1 stop bit.
transport='realcom' | 'tcp' | 'serial'overrides the automatic choice, e.g. for a Real COM device with a non-default data port.- Serial settings default to the D50's 9600 8N1:
baudrate,parity,databitsandstopbitsarguments (or attributes). They are applied to a local serial port, and to the MOXA's port in Real COM mode. cpu_id(attributeCPUID) is the PLC station number, default 255.comm.ConnectionDescriptionshows what a PLC object will connect to.
from pyd50 import PLC
with PLC('100.100.100.254') as comm:
print(comm.Read('W0')) # W0 1234 Success
print(comm.Read('W10', 5)) # five consecutive words
print(comm.Read('W4', datatype='REAL')) # W4 1.5 Success
print(comm.Read('R015.05')) # R015.05 False Success
print(comm.Read(['TC099', 'PV100', 'SR0'])) # several operands, one request
comm.Write('W0', 1234)
comm.Write('W2', 100000, 'DWORD')
comm.Write('M031.15', True)
comm.Write([('W0', 7), ('W4', 2.5, 'REAL'), ('M031.15', 0)])
print(comm.GetPLCMode()) # Mode RUN Success
comm.Stop()
comm.Run()
comm.Upload('program.json') # PLC -> file
print(comm.VerifyProgram('program.json')) # [] = identical
comm.Download('program.json') # file -> PLCOperands are written as WinGPC shows them. Bit numbers are 00-15, in decimal.
| Area | Example | Meaning |
|---|---|---|
| R | R015.05, R15 |
I/O relays (bit or word) |
| M | M001.10, M1 |
internal relays |
| K | K003.00, K3 |
keep relays |
| F | F000.15, F0 |
special flags (F000.15 is the RUN flag) |
| TC | TC099 |
timer/counter done contact (always a bit) |
| W | W0 |
data words |
| SV | SV10 |
timer/counter set values |
| PV | PV10 |
timer/counter present values |
| SR | SR0 |
system registers (SR0 = CPU ID, SR2 = watchdog, SR3/SR4 = scan time) |
A D50 timer or counter (e.g. the TIM Ch=00100 box in WinGPC) is spread over
three areas, all addressed by its channel number. They can be named directly or
the pylogix way:
| Alias | Same as | Meaning |
|---|---|---|
T100.PV or T100.ACC |
PV100 |
present (accumulated) value |
T100.SV or T100.PRE |
SV100 |
set value held in the PLC |
T100.DN |
TC100 |
done contact |
T100 |
— | all three in one request: {'PV': 6, 'SV': 10, 'DN': False} |
C<n> is the same for counters, since timers and counters share channel
numbers. The aliases work wherever an address does: reads, writes, arrays and
batches. A whole T100 can be read but not written. Write T100.SV and the
other members individually.
PV values are raw counts. WinGPC shows them in hex, and on the test PLC a timer's PV was seen to exceed its SV, so check the units against a known timer before relying on them.
Word operands read as WORD (unsigned 16-bit) unless a datatype is given:
INT, DWORD, DINT, REAL (2 words) or LREAL (4 words). Multi-word
values are stored low word first, which is how WinGPC's Change Value writes
them. Bit operands read and write as True/False.
| Method | Description |
|---|---|
Read(tag, count=1, datatype=None) |
One operand, count consecutive operands, or a list of operands / (tag, count, datatype) tuples |
Write(tag, value, datatype=None) |
One operand, consecutive operands (value is a list), or a list of (tag, value[, datatype]) |
GetPLCMode() / SetPLCMode(mode) |
'RUN' or 'STOP' |
Run() / Stop() |
Put the CPU in RUN / STOP |
GetDeviceProperties() |
CPU ID, project name, used memory, steps, mode, watchdog, scan times, flags |
GetProjectName() |
Project name stored in the PLC |
VerifyPassword(password) |
True if the password matches the PLC's |
GetErrorTable() |
Raw words SR32-SR46 that WinGPC reads for its error table |
Upload(filename=None) |
Read the program; Value is a Program, saved as JSON if a filename is given |
Download(program, run=None) |
Write a Program or program file. run=None returns to the previous mode |
VerifyProgram(program) |
List of program words that differ from the PLC (empty = identical) |
ReadParameter(address, count) / WriteParameter(address, words) |
Raw parameter/program area access |
Message(command, data) |
Send any D50 command, return the raw reply |
Close() |
Close the connection (also done by with) |
Failures never raise: Status says what went wrong, e.g. Timeout - No reply from the PLC, Invalid parameter value - ..., or PLC error - Command 0x06 returned status 02 (the PLC rejects an address beyond the end of an area).
Download follows WinGPC's sequence:
- Stop the CPU.
- Write
0xFC97over the first program word, which marks the program invalid. - Write the program, the end marker, the project name and the size.
- Write the real first word, which marks the program valid.
- Restore the previous mode.
If the download is interrupted, the CPU stays in STOP with an invalid
program; it never runs a half-written one. Run Download again to finish.
These attributes can be changed on the PLC object:
SocketTimeout(2 s) andRetries(2) control waiting and retrying. Timeouts and CRC errors are retried.FrameDelay(0.02 s) is the pause between frames.- The block sizes are
MaxReadWords,MaxReadBits,MaxWriteWords,MaxBatchItems,MaxParameterReadandMaxParameterWrite. Their defaults are the largest sizes WinGPC was seen to use.
- Frame:
[dest][source][cmd][len][data...][crc lo][crc hi]. - CRC: CRC-16/MODBUS.
- Addresses: the PLC is its CPU ID; the PC is
0xE1. - Exchange: each command is acknowledged (
0x80 00). The PC then polls (0x00), and the PLC replies with0x80 | cmd.
| Cmd | Function |
|---|---|
| 01 / 02 | read bits / write one bit |
| 03 / 04 | read / write words |
| 05 / 06 | random read / write (mixed bit and word items) |
| 07 / 08 | read / write the parameter and program area |
| 10 | begin program download |
A full description is in docs/D50_WinGPC_Serial_Protocol.docx.
python -m unittest discover tests
The tests use tests/d50_simulator.py, a simulated D50 that listens on TCP.
It also works as a stand-in PLC for trying out code:
python tests/d50_simulator.py --port 4001
python -c "from pyd50 import PLC; print(PLC('127.0.0.1:4001').Read('SR0'))"
pyD50 was tested against a D50 CPU-14 (ROM 2.08) through a MOXA DE-311 in Real COM mode. TCP Server mode and local serial ports use the same framing, but they have only been tested against the simulator.
MIT. See LICENSE.txt.