frappy_mlz: Add Zapf PLC
adds a zapf-based PLC connection scanner. Change-Id: Icc0ded7e7a8cc5a83d7527d9b26b37c49e9b8674 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31471 Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de> Reviewed-by: Enrico Faulhaber <enrico.faulhaber@frm2.tum.de> Reviewed-by: Alexander Zaft <a.zaft@fz-juelich.de>
This commit is contained in:
parent
d726fd9fa0
commit
7a2e764262
363
frappy_mlz/plc_zapf.py
Normal file
363
frappy_mlz/plc_zapf.py
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
# *****************************************************************************
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify it under
|
||||||
|
# the terms of the GNU General Public License as published by the Free Software
|
||||||
|
# Foundation; either version 2 of the License, or (at your option) any later
|
||||||
|
# version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
|
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||||
|
#
|
||||||
|
# Module authors:
|
||||||
|
# Alexander Zaft <a.zaft@fz-juelich.de>
|
||||||
|
#
|
||||||
|
# *****************************************************************************
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import zapf
|
||||||
|
import zapf.spec as zspec
|
||||||
|
from zapf.io import PlcIO
|
||||||
|
from zapf.scan import Scanner
|
||||||
|
|
||||||
|
from frappy.core import BUSY, DISABLED, ERROR, FINALIZING, IDLE, \
|
||||||
|
INITIALIZING, STARTING, UNKNOWN, WARN, Attached, Command, Communicator, \
|
||||||
|
Drivable, Parameter, Property, Readable
|
||||||
|
from frappy.datatypes import UNLIMITED, ArrayOf, BLOBType, EnumType, \
|
||||||
|
FloatRange, IntRange, StatusType, StringType, ValueType
|
||||||
|
from frappy.dynamic import Pinata
|
||||||
|
from frappy.errors import CommunicationFailedError, ImpossibleError, \
|
||||||
|
IsBusyError, NoSuchParameterError, ReadOnlyError
|
||||||
|
|
||||||
|
# Untested with real hardware, only testplc_2021_09.py
|
||||||
|
|
||||||
|
|
||||||
|
def internalize_name(name):
|
||||||
|
return re.sub(r'[^a-zA-Z0-9_]+', '_', name, re.ASCII)
|
||||||
|
|
||||||
|
|
||||||
|
ERROR_MAP = {
|
||||||
|
# should not happen. but better to have it here anyway
|
||||||
|
5: NoSuchParameterError,
|
||||||
|
# if this occurs, something may have gone wrong with digesting the scanner
|
||||||
|
# data
|
||||||
|
6: ReadOnlyError,
|
||||||
|
# Most likely from devices you cannot poll when busy.
|
||||||
|
7: IsBusyError,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ZapfPinata(Pinata):
|
||||||
|
"""The Pinata device for a PLC that can be accessed according to PILS.
|
||||||
|
|
||||||
|
See https://forge.frm2.tum.de/public/doc/plc/master/html/
|
||||||
|
|
||||||
|
Instantiates the classes with the base mapped class, which will be replaced
|
||||||
|
by initModule, so modules can also be configured manually in the config
|
||||||
|
file.
|
||||||
|
"""
|
||||||
|
iodev = Property('Connection to PLC', StringType())
|
||||||
|
|
||||||
|
def scanModules(self):
|
||||||
|
try:
|
||||||
|
self._plcio = PlcIO(self.iodev, self.log)
|
||||||
|
except zapf.CommError as e:
|
||||||
|
raise CommunicationFailedError('could not connect to plc') from e
|
||||||
|
scanner = Scanner(self._plcio, self.log)
|
||||||
|
for devinfo in scanner.scan_devices():
|
||||||
|
if zspec.LOWLEVEL in devinfo.info.get('flags'):
|
||||||
|
self.log.debug('device %d (%s) is lowlevel, skipping',
|
||||||
|
devinfo.number, devinfo.name)
|
||||||
|
continue
|
||||||
|
device = scanner.get_device(devinfo)
|
||||||
|
if device is None:
|
||||||
|
self.log.info(f'{devinfo.name} unsupported')
|
||||||
|
continue
|
||||||
|
basecls = CLS_MAP.get(device.__class__, None)
|
||||||
|
if basecls is None:
|
||||||
|
self.log.info('No mapping found for %s, (class %s)',
|
||||||
|
devinfo.name, device.__class__.__name__)
|
||||||
|
continue
|
||||||
|
mod_cls = basecls.makeModuleClass(device, devinfo)
|
||||||
|
config = {
|
||||||
|
'cls': mod_cls,
|
||||||
|
'plcio': device,
|
||||||
|
'description': devinfo.info['description'],
|
||||||
|
'plc_name': devinfo.name,
|
||||||
|
'_pinata': self.name,
|
||||||
|
}
|
||||||
|
if devinfo.info['basetype'] != 'enum' \
|
||||||
|
and not issubclass(basecls, PLCCommunicator):
|
||||||
|
config['value'] = {
|
||||||
|
# internal limit here is 2**64, zapf reports 2**128
|
||||||
|
'min': max(devinfo.info['absmin'], -UNLIMITED),
|
||||||
|
'max': min(devinfo.info['absmax'], UNLIMITED),
|
||||||
|
}
|
||||||
|
if devinfo.info['access'] == 'rw':
|
||||||
|
config['target'] = {
|
||||||
|
'min': config['value']['min'],
|
||||||
|
'max': config['value']['max'],
|
||||||
|
}
|
||||||
|
name = internalize_name(devinfo.name)
|
||||||
|
yield (name, config)
|
||||||
|
self._plcio.start_cache()
|
||||||
|
|
||||||
|
def shutdownModule(self):
|
||||||
|
"""Shutdown the module, _plcio might be invalid after this. Needs to be
|
||||||
|
recreated by scanModules."""
|
||||||
|
self._plcio.stop_cache()
|
||||||
|
self._plcio.proto.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_MAP = {
|
||||||
|
zspec.DevStatus.RESET: (INITIALIZING, 'resetting'),
|
||||||
|
zspec.DevStatus.IDLE: (IDLE, 'idle'),
|
||||||
|
zspec.DevStatus.DISABLED: (DISABLED, 'disabled'),
|
||||||
|
zspec.DevStatus.WARN: (WARN, 'warning'),
|
||||||
|
zspec.DevStatus.START: (STARTING, 'starting'),
|
||||||
|
zspec.DevStatus.BUSY: (BUSY, 'busy'),
|
||||||
|
zspec.DevStatus.STOP: (FINALIZING, 'stopping'),
|
||||||
|
zspec.DevStatus.ERROR: (ERROR, 'error (please reset)'),
|
||||||
|
zspec.DevStatus.DIAGNOSTIC_ERROR: (ERROR, 'hard error (please check plc)'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PLCBase:
|
||||||
|
status = Parameter(datatype=StatusType(Drivable, 'INITIALIZING',
|
||||||
|
'DISABLED', 'STARTING'))
|
||||||
|
status_code = Parameter('raw internal status code',
|
||||||
|
IntRange(0, 2**32-1))
|
||||||
|
plcio = Property('plc io device', ValueType())
|
||||||
|
plc_name = Property('plc io device', StringType(), export=True)
|
||||||
|
_pinata = Attached(ZapfPinata) # TODO: make this automatic?
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def makeModuleClass(cls, device, devinfo):
|
||||||
|
# add parameters and commands according to device info
|
||||||
|
add_members = {}
|
||||||
|
# set correct enums for value/target
|
||||||
|
if devinfo.info['basetype'] == 'enum':
|
||||||
|
rmap = {v: k for k, v in devinfo.info['enum_r'].items()}
|
||||||
|
read_enum = EnumType(rmap)
|
||||||
|
add_members['value'] = Parameter(datatype=read_enum)
|
||||||
|
if hasattr(cls, 'target'):
|
||||||
|
#wmap = {k:v for k, v in devinfo.info['enum_w'].items()}
|
||||||
|
#write_enum = EnumType(wmap)
|
||||||
|
write_enum = EnumType(devinfo.info['enum_w'])
|
||||||
|
add_members['target'] = Parameter(datatype=write_enum)
|
||||||
|
|
||||||
|
for parameter in device.list_params():
|
||||||
|
info = devinfo.info['params'][parameter]
|
||||||
|
iname = internalize_name(parameter)
|
||||||
|
readonly = info.get('access', 'ro') != 'rw'
|
||||||
|
dataty = cls._map_datatype(info)
|
||||||
|
if dataty is None:
|
||||||
|
continue
|
||||||
|
param = Parameter(info['description'],
|
||||||
|
dataty,
|
||||||
|
readonly=readonly)
|
||||||
|
|
||||||
|
def read_param(self, parameter=parameter):
|
||||||
|
code, val = self.plcio.get_param_raw(parameter)
|
||||||
|
if code > 4:
|
||||||
|
raise ERROR_MAP[code](f'Error when reading parameter'
|
||||||
|
f'{parameter}: {code}')
|
||||||
|
return val
|
||||||
|
|
||||||
|
def write_param(self, value, parameter=parameter):
|
||||||
|
code, val = self.plcio.set_param_raw(parameter, value)
|
||||||
|
if code > 4:
|
||||||
|
raise ERROR_MAP[code](f'Error when setting parameter'
|
||||||
|
f'{parameter} to {value!r}: {code}')
|
||||||
|
return val
|
||||||
|
|
||||||
|
# enums can have asymmetric read and write variants. this should be
|
||||||
|
# checked
|
||||||
|
if info['basetype'] == 'enum':
|
||||||
|
allowed = frozenset(info['enum_w'].values())
|
||||||
|
#pylint: disable=function-redefined
|
||||||
|
def write_param(self, value, allowed=allowed, parameter=parameter):
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(f'Invalid value for writing'
|
||||||
|
f' {parameter}: {value!r}')
|
||||||
|
|
||||||
|
code, val = self.plcio.set_param_raw(parameter, value)
|
||||||
|
if code > 4:
|
||||||
|
raise ERROR_MAP[code](f'Error when setting parameter'
|
||||||
|
f'{parameter} to {value!r}: {code}')
|
||||||
|
return val
|
||||||
|
|
||||||
|
add_members[iname] = param
|
||||||
|
add_members['read_' + iname] = read_param
|
||||||
|
if readonly:
|
||||||
|
continue
|
||||||
|
add_members['write_' + iname] = write_param
|
||||||
|
|
||||||
|
for command in device.list_funcs():
|
||||||
|
info = devinfo.info['funcs'][command]
|
||||||
|
iname = internalize_name(command)
|
||||||
|
if info['argument']:
|
||||||
|
arg = cls._map_datatype(info['argument'])
|
||||||
|
else:
|
||||||
|
arg = None
|
||||||
|
if info['result']:
|
||||||
|
result = cls._map_datatype(info['result'])
|
||||||
|
else:
|
||||||
|
result = None
|
||||||
|
def exec_command(self, arg=None, command=command):
|
||||||
|
# TODO: commands return <err/succ>, <result>
|
||||||
|
return self.plcio.exec_func(command, arg)
|
||||||
|
decorator = Command(arg,
|
||||||
|
result = result,
|
||||||
|
description=info['description'],
|
||||||
|
)
|
||||||
|
|
||||||
|
func = decorator(exec_command)
|
||||||
|
add_members['call_' + iname] = func
|
||||||
|
if not add_members:
|
||||||
|
return cls
|
||||||
|
new_name = '_' + cls.__name__ + '_' \
|
||||||
|
+ internalize_name("blub")
|
||||||
|
return type(new_name, (cls,), add_members)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _map_datatype(cls, info):
|
||||||
|
dataty = info['basetype']
|
||||||
|
if dataty == 'int':
|
||||||
|
return IntRange(info['min_value'], info['max_value'])
|
||||||
|
if dataty == 'float':
|
||||||
|
return FloatRange(info['min_value'], info['max_value'])
|
||||||
|
if dataty == 'enum':
|
||||||
|
mapping = {v: k for k, v in info['enum_r'].items()}
|
||||||
|
return EnumType(mapping)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read_status(self):
|
||||||
|
state, reason, aux, err_id = self.plcio.read_status()
|
||||||
|
if state in STATUS_MAP:
|
||||||
|
status, m = STATUS_MAP[state]
|
||||||
|
else:
|
||||||
|
status, m = UNKNOWN, 'unknown state 0x%x' % state
|
||||||
|
msg = [m]
|
||||||
|
reason = zapf.spec.ReasonMap[reason]
|
||||||
|
if reason:
|
||||||
|
msg.append(reason)
|
||||||
|
if aux:
|
||||||
|
msg.append(self.plcio.decode_aux(aux))
|
||||||
|
if err_id:
|
||||||
|
msg.append(self.plcio.decode_errid(err_id))
|
||||||
|
return status, ', '.join(msg)
|
||||||
|
|
||||||
|
def read_status_code(self):
|
||||||
|
state, reason, aux, _ = self.plcio.read_status()
|
||||||
|
return state << 28 | reason << 24 | aux
|
||||||
|
|
||||||
|
@Command()
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the operation of this module.
|
||||||
|
|
||||||
|
:raises:
|
||||||
|
ImpossibleError: if the command is called while the module is
|
||||||
|
not busy
|
||||||
|
"""
|
||||||
|
if not self.plcio.change_status((zapf.DevStatus.BUSY,),
|
||||||
|
zapf.DevStatus.STOP):
|
||||||
|
self.log.info('stop was called when device was not busy')
|
||||||
|
# TODO: off/on?
|
||||||
|
|
||||||
|
@Command()
|
||||||
|
def reset(self):
|
||||||
|
"""Tries to reset this module.
|
||||||
|
|
||||||
|
:raises:
|
||||||
|
ImpossibleError: when called while the module is not in an error
|
||||||
|
state.
|
||||||
|
"""
|
||||||
|
if not self.plcio.reset():
|
||||||
|
raise ImpossibleError('reset called when the device is not in'
|
||||||
|
'an error state!')
|
||||||
|
|
||||||
|
|
||||||
|
class PLCValue(PLCBase):
|
||||||
|
"""Base class for all but Communicator"""
|
||||||
|
def read_value(self):
|
||||||
|
return self.plcio.read_value_raw() # read_value maps enums on zapf side
|
||||||
|
|
||||||
|
def read_target(self):
|
||||||
|
return self.plcio.read_target_raw()
|
||||||
|
|
||||||
|
def write_target(self, value):
|
||||||
|
self.plcio.change_target_raw(value)
|
||||||
|
|
||||||
|
|
||||||
|
class PLCReadable(PLCValue, Readable):
|
||||||
|
"""Readable value, scanned from PLC."""
|
||||||
|
description = Property('the modules description',
|
||||||
|
datatype=StringType(isUTF8=True))
|
||||||
|
|
||||||
|
|
||||||
|
class PLCDrivable(PLCValue, Drivable):
|
||||||
|
"""Drivable, scanned from PLC."""
|
||||||
|
description = Property('the modules description',
|
||||||
|
datatype=StringType(isUTF8=True))
|
||||||
|
|
||||||
|
|
||||||
|
class PLCCommunicator(PLCBase, Communicator):
|
||||||
|
status = Parameter('current status of the module')
|
||||||
|
|
||||||
|
@Command(BLOBType(), result=BLOBType())
|
||||||
|
def communicate(self, command):
|
||||||
|
return self.plcio.communicate(command)
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor(PLCReadable):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AnalogOutput(PLCDrivable):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DiscreteInput(PLCReadable):
|
||||||
|
value = Parameter(datatype=IntRange())
|
||||||
|
|
||||||
|
class DiscreteOutput(PLCDrivable):
|
||||||
|
value = Parameter(datatype=IntRange())
|
||||||
|
target = Parameter(datatype=IntRange())
|
||||||
|
|
||||||
|
|
||||||
|
class VectorInput(PLCReadable):
|
||||||
|
value = Parameter(datatype=ArrayOf(FloatRange()))
|
||||||
|
|
||||||
|
|
||||||
|
class VectorOutput(PLCDrivable):
|
||||||
|
value = Parameter(datatype=ArrayOf(FloatRange()))
|
||||||
|
target = Parameter(datatype=ArrayOf(FloatRange()))
|
||||||
|
|
||||||
|
|
||||||
|
CLS_MAP = {
|
||||||
|
zapf.device.SimpleDiscreteIn: DiscreteInput,
|
||||||
|
zapf.device.SimpleAnalogIn: Sensor,
|
||||||
|
zapf.device.Keyword: DiscreteOutput,
|
||||||
|
zapf.device.RealValue: AnalogOutput,
|
||||||
|
zapf.device.SimpleDiscreteOut: DiscreteOutput,
|
||||||
|
zapf.device.SimpleAnalogOut: PLCDrivable,
|
||||||
|
zapf.device.StatusWord: DiscreteInput,
|
||||||
|
zapf.device.DiscreteIn: DiscreteInput,
|
||||||
|
zapf.device.AnalogIn: Sensor,
|
||||||
|
zapf.device.DiscreteOut: DiscreteOutput,
|
||||||
|
zapf.device.AnalogOut: PLCDrivable,
|
||||||
|
zapf.device.FlatIn: Sensor,
|
||||||
|
zapf.device.FlatOut: AnalogOutput,
|
||||||
|
zapf.device.ParamIn: Sensor,
|
||||||
|
zapf.device.ParamOut: AnalogOutput,
|
||||||
|
zapf.device.VectorIn: VectorInput,
|
||||||
|
zapf.device.VectorOut: VectorOutput,
|
||||||
|
zapf.device.MessageIO: PLCCommunicator,
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user