diff --git a/bin/mk b/bin/mk index 777f6c2f..f4647bde 100755 --- a/bin/mk +++ b/bin/mk @@ -115,7 +115,7 @@ def parse_command_line(): parser.add_argument('-u', '--units', default=None, - help='Use the requested units for getting or setting tem values. Note that this option is applied to all requests, and is not practical when interacting with multiple items.') + help='Use the requested units for getting or setting item values. Note that this option is applied to all requests, and is not practical when interacting with multiple items.') parser.add_argument('type', choices=('get', 'set', 'watch', 'describe', 'discover', 'list'), diff --git a/doc/mk.txt b/doc/mk.txt index 9e1c265a..debaa700 100644 --- a/doc/mk.txt +++ b/doc/mk.txt @@ -41,7 +41,7 @@ options: --no-timestamp Omit the timestamp from the output of 'watch' requests. The default is to include the timestamp for 'watch' requests, and omit it from 'get' requests. - -u, --units UNITS Use the requested units for getting or setting tem + -u, --units UNITS Use the requested units for getting or setting item values. Note that this option is applied to all requests, and is not practical when interacting with multiple items. diff --git a/examples/EPICS.py b/examples/EPICS.py new file mode 100644 index 00000000..477de2cc --- /dev/null +++ b/examples/EPICS.py @@ -0,0 +1,237 @@ +""" This is an implementation of a EPICS proxy, enabling full mKTL commands + to a EPICS channel. +""" + +import mktl +import epics +import itertools + + +class Daemon(mktl.Daemon): + + def __init__(self, store, alias=None, *args, **kwargs): + + # Generate the configuration matching this set of EPICS channels. Since this + # configuration is not in the default location it must be declared + # prior to initializing the Daemon. + # store is the mktl store name for the epics object (i.e. k1:mySystem:myDevice) + # alias is the name mKTL can use if you don't want to use pvname (i.e. myDevice) + mktl.Daemon.__init__(self, store, alias, *args, **kwargs) + + def setup(self): + """ The only reason this method exists is to create a EPICS.Item + instance for each and every channel being proxied by this + daemon, as opposed to letting them be the default mktl.Item. + """ + keys = self.config.keys(authoritative=True) + + for key in keys: + self.add_item(Item, key) + + + def setup_final(self): + """ This is the last step before broadcasts go out. This is the + right time to fire up monitoring of all EPICS PVs. + """ + keys = self.config.keys(authoritative=True) + for key in keys: + if key.startswith('_'): # may not need this anymore + continue + item = self.store[key] + pvname = self.config[key]['channel'] + pv = epics.PV(pvname) + pv.add_callback(item.publish_broadcast) + + +# end of class Store + + + +class Item(mktl.Item): + + def __init__(self, *args, **kwargs): + mktl.Item.__init__(self, *args, **kwargs) + + # We want the EPICS channels to be the sole source of broadcast + # events. mKTL defaults to publishing a new value when a SET operation + # completes successfully; this attribute inhibits that behavior. + self.publish_on_set = False + + def publish_broadcast(self, *args, **kwargs): + """ This method is registered as an EPICS callback; take any/all EPICS + broadcast events and publish them as mKTL events. + + Epics callback functions are called with several keyword arguments. + you should always include **kwargs in your callback definition to + capture all of them. Here are some example callback arguments: + pvname : Name of the PV that triggered the callback + value : Current value of the PV + type: : the Python type for the data + units : string for PV units + See https://pyepics.github.io/pyepics/pv.html#user-supplied-callback-functions + for an exhaustive list of callback arguments. + """ + try: + value = kwargs['value'] + except KeyError: + return + timestamp = self._get_timestamp(kwargs.get('timestamp')) + self.publish(value, timestamp) # Publish will pick up the timestamp value if it is None. + + def _get_timestamp(self, timestamp, minlim=915184800): + """Check if there is a timestamp. If it is None or before 1999-01-01, + return None to let mKTL handle it. Otherwise return the timestamp. + """ + if timestamp is None: + return None + elif timestamp < minlim: # this is unix timestamp for 1999-01-01 + return None + else: + return timestamp + + def _get_pv_with_metadata(self): + """ Return the EPICS PV object associated with this item. + """ + pv = epics.PV(self.config['channel']) + resp = None + tries = 0 + while resp is None: # try up to 5 times to get a valid response + resp = pv.get_with_metadata(as_string=True) # get the value and metadata + tries += 1 + if tries >= 5: + raise RuntimeError(f"Could not get metadata for PV {self.config['channel']}") + return resp + + + def perform_get(self): + """ Wrap an incoming GET request to a Epics channel get. This method + is only invoked on synchronous GET requests, normally it would + also be invoked when local polling occurs, but this wrapper + relies on epics callbacks to receive asynchronous broadcasts + (see :func:`publish_broadcast`). + """ + if 'channel' not in self.config.keys(): + return None + resp = self._get_pv_with_metadata() + timestamp = self._get_timestamp(resp.get('timestamp')) + value = resp.get('value') + payload = mktl.Payload(value, timestamp) + return payload + + + def perform_set(self, new_value): + """ Wrap an incoming SET request to a Epics channel put. This method + is expected to block until completion of the request. The values + presented at this level are the equivalent of the KTL binary + value, and this needs to be asserted explicitly at the KTL level + to ensure they are interpreted (or not interpreted, as the case + may be) properly. + """ + if not self.config.get('settable'): + return None + pv = epics.PV(self.config['channel']) + pv.put(new_value, wait=True) + +# end of class Item + + +def describeChannel(name): + """ Construct an mKTL configuration block to describe the named Epics channel. + """ + pv = epics.PV(name) + slice = pv.get_with_metadata(as_string=True) # populate metadata + return slice + + +def describePV(pv: epics.PV): + """ Construct an item-specific mKTL configuration block for a single + Epics channel. + """ + + channel_dict = dict() + + type = pv.type + type = type_mapping[type.upper()] + channel_dict['type'] = type + enumerators = None + + try: + enumerators = pv['enum_strs'] + except: + pass + + if enumerators: # ignore empty enumerators + rebuilt = dict() + for key in range(len(enumerators)): + enumerator = enumerators[key] + if enumerator == '': + continue + else: + rebuilt[key] = enumerator + + enumerators = rebuilt + + for attribute in ('units', 'info'): + try: + value = getattr(pv, attribute) + except ValueError: + value = None + + if attribute == 'units' and enumerators is not None: + # Keywords with enumerators overload the 'units' string in order + # to provide the enumerators. Including the 0th enumerator again + # here would be a mistake. + value = None + + if value is not None: + if attribute == 'help': + attribute = 'description' + channel_dict[attribute] = value + + # make range attribute + try: + lower = getattr(pv, 'lower_ctrl_limit') + upper = getattr(pv, 'upper_ctrl_limit') + except ValueError: + pass + else: + channel_dict['range'] = {"minimum": lower, "maximum": upper} + + for attribute in ('key', 'read_access', 'write_access'): + try: + if attribute == 'key': + value = pv.config['channel'] + else: + value = getattr(pv, attribute) + except ValueError: + value = None + + if value is False: + channel_dict[attribute] = value + + if enumerators is not None: + channel_dict['enumerators'] = enumerators + + return channel_dict + + +# Translate Epics data types to mKTL types. + +type_mapping = dict() +epics_types = ['double', 'float', 'int', 'string', 'short', 'enum', 'char', 'long'] +numeric_types = set(('double', 'float', 'short', 'int', 'char', 'long')) +epics_variants = ['', 'ctrl', 'time'] +for v, t in itertools.product(epics_variants, epics_types): + if v == '': + epics_type = t.upper() + else: + epics_type = f'{v.upper()}_{t.upper()}' + if t in numeric_types: + mktl_type = 'numeric' + elif t == 'string': + mktl_type = 'string' + elif t == 'enum': + mktl_type = 'enumerated' + type_mapping[epics_type] = mktl_type + +# vim: set expandtab tabstop=8 softtabstop=4 shiftwidth=4 autoindent: diff --git a/examples/fakedcs_cake_config b/examples/fakedcs_cake_config new file mode 100644 index 00000000..2bdf0270 --- /dev/null +++ b/examples/fakedcs_cake_config @@ -0,0 +1,258 @@ +# number of CA events to skip per event handled (now defaulted to 0 anyway) +skip_num = 0 + +# The "%" is replaced with the trailing character from the service (if it +# is a digit). This file can therefore be used with dcs0, dcs1, dcs2 etc. + +prefix = fakedcs: +simprefix = fakedcs: + +# use prefix flag ------------------ +# type --------------- | +# asc2bin method ----------- | | +# bin2asc method ------ | | | +# | | | | +# keyword CA name for read CA name for write V V V V +# +# -- in alphabetical order of KTL keyword name -- + + guider-active in {no,yes} + AUTACTIV AUTACTIV AUTACTIV BOO BOO b 1 + + guider-go-flag in "" + AUTGO AUTGO AUTGO STR STR s 1 + + guider-pause in "" + AUTPAUSE AUTPAUSE AUTPAUSE INT INT i 1 + + guider-resume in "" + AUTRESUM AUTRESUM AUTRESUM INT INT i 1 + + guider-resumeX in arcsec + AUTRESX AUTRESX AUTRESX R2A A2R d 1 + + guider-resumeY in arcsec + AUTRESY AUTRESY AUTRESY R2A A2R d 1 + + guider-seqnum in "" + AUTSQNUM AUTSQNUM AUTSQNUM INT INT i 1 + + guider-stop in "" + AUTSTOP AUTSTOP AUTSTOP INT INT i 1 + + guider-Xcent in arcsec + AUTXCENT AUTXCENT AUTXCENT R2A A2R d 1 + + guider-Ycent in arcsec + AUTYCENT AUTYCENT AUTYCENT R2A A2R d 1 + + axes-control-status in {unknown, + not controlling, + halted, + in position, + in limit, + slewing, + acquiring, + tracking} + AXESTAT AXESTAT AXESTAT ENMM ENMM em 1 + + telescope-azimuth in deg + AZ AZ AZ R2D2 D2R d 1 + + current-instrument in "" + CURRINST CURRINST CURRINST STR STR s 1 + + telescope-elevation in deg + EL EL EL R2D2 D2R d 1 + + parallactic-angle-astrometric in deg + PARANG PARANG PARANG R2D2 D2R d 1 + + rotator-base-angle in deg + ROTBASE ROTBASE ROTBASE R2D2 D2R d 1 + + rotator-user-destination in deg + ROTDEST ROTDEST ROTDEST R2D D2R d 1 + +# Note: timestamps are not specific to rotator but only use will be for rot? +# Can always define additional keywords connected to these channels + rotator-destination-timestamp1-ut1-slow in "" + ROTDETS1 ROTDETS1 ROTDETS1 UTC DBL d 1 + + rotator-destination-timestamp2-ut1-slow in "" + ROTDETS2 ROTDETS2 ROTDETS2 UTC DBL d 1 + +# Writable so external rotator can set it + rotator-error-string in "" + ROTERRS ROTERRS ROTERRS STR STR s 1 + +# Writable so external rotator can set it + rotator-error-status in "" + ROTERVL ROTERVL ROTERVL INT INT i 1 + + rotator-halt-command in "" + ROTHALT ROTHALT ROTHALTC BOO BOO b 1 + +# Can be used by external rotator to intercept command + rotator-halt-command-complement in "" + ROTHALTC ROTHALTC ROTHALT BOO BOO b 1 + + rotator-initialization-command in "" + ROTINIT ROTINIT ROTINITC BOO BOO b 1 + +# Can be used by external rotator to intercept command + rotator-initialization-command-complement in "" + ROTINITC ROTINITC ROTINIT BOO BOO b 1 + + rotator-tracking-mode in {unknown, + position angle, + vertical angle, + stationary} + ROTMODE ROTMODE ROTMODE ENMM ENMM em 1 + + rotator-new-destination-slow in "" + ROTNEWS ROTNEWS ROTNEWS INT INT i 1 + + rotator-physical-destination in deg + ROTPDEST ROTPDEST ROTPDEST R2D D2R d 1 + + rotator-physical-destination-slow in deg + ROTPDSTS ROTPDSTS ROTPDSTS R2D D2R d 1 + + rotator-user-position in deg + ROTPOSN ROTPOSN ROTPOSN R2D D2R d 1 + + rotator-position-timestamp1-ut1-slow in "" + ROTPOTS1 ROTPOTS1 ROTPOTS1 UTC DBL d 1 + + rotator-position-timestamp2-ut1-slow in "" + ROTPOTS2 ROTPOTS2 ROTPOTS2 UTC DBL d 1 + + rotator-physical-position in deg + ROTPPOSN ROTPPOSN ROTPPOSN R2D D2R d 1 + + rotator-select in {none, + fcass (forward cass), + cass (cassegrain), + lbc (left bent cass), + rbc (right bent cass)} + ROTSEL ROTSEL ROTSEL ENM ENM e 1 + +# This is not a real dcs keyword. The ROTSPEED allows the developer to +# change the commanded speed from the nominal default of 0.1 deg/sec. + + rotator-fake-speed in deg/sec + ROTSPEED ROTSPEED ROTSPEED DBL DBL d 1 + + rotator-servo-error in arcsec + ROTSRVER ROTSRVER ROTSRVER R2A3 A2R d 1 + +# Writable so external rotator can set it + rotator-state in {unknown, + off, + initializing, + standby, + tracking, + fault, + manual, + halted, + disabled, + in position, + slewing, + acquiring, + in limit} + ROTSTAT ROTSTAT ROTSTAT ENMM ENMM em 1 + + rotator-standby-command in "" + ROTSTBY ROTSTBY ROTSTBYC BOO BOO b 1 + +# Can be used by external rotator to intercept command + rotator-standby-command-complement in "" + ROTSTBYC ROTSTBYC ROTSTBY BOO BOO b 1 + +# Writable so external rotator can set it + rotator-state-string in "" + ROTSTST ROTSTST ROTSTST STR STR s 1 + +# rot IOC heartbeat + rotator-current-time in "" + ROTTIME ROTTIME ROTTIME UTC DBL d 1 + + simulating-dcs in {false, true} + SIMULATE SIMULATE SIMULATE BOO BOO b 1 + + tertiary-error-string in "" + TERTERRS TERTERRS TERTERRS STR STR s 1 + + tertiary-error-status in "" + TERTERVL TERTERVL TERTERRS INT INT i 1 + + tertiary-halt-command in "" + TERTHALT TERTHALT TERTHALTC BOO BOO b 1 + + tertiary-halt-command-complement in "" + TERTHALTC TERTHALTC TERTHALT BOO BOO b 1 + + tertiary-initialization-command in "" + TERTINIT TERTINIT TERTINITC BOO BOO b 1 + + tertiary-initialization-command-complement in "" + TERTINITC TERTINITC TERTINIT BOO BOO b 1 + + move-tertiary in no,yes + TERTMOVE TERTMOVE TERTMOVEC BOO BOO b 1 + + move-tertiary-complement in no,yes + TERTMOVEC TERTMOVEC TERTMOVE BOO BOO b 1 + + tertiary-destination in {lnas, + lbc1, + lbc2, + stowed, + rbc2, + rbc1, + rnas, + cass, + mirrorup} + TERTDEST TERTDEST TERTDEST ENM ENM e 1 + + tertiary-position in {lnas, + lbc1, + lbc2, + stowed, + rbc2, + rbc1, + rnas, + unknown, + cass, + mirrorup} + TERTPOSN TERTPOSN TERTPOSN ENM ENM e 1 + + tertiary-state in {unknown, + off, + initializing, + standby, + tracking, + fault, + manual, + halted, + disabled, + in position, + slewing, + acquiring, + in limit} + TERTSTAT TERTSTAT TERTSTAT ENMM ENMM em 1 + + tertiary-standby-command in "" + TERTSTBY TERTSTBY TERTSTBYC BOO BOO b 1 + + tertiary-standby-command-complement in "" + TERTSTBYC TERTSTBYC TERTSTBY BOO BOO b 1 + + tertiary-state-string in "" + TERTSTST TERTSTST TERTSTST STR STR s 1 + +# Note: is not UTC until timConvert dead-band handling is sorted out + coordinated-universal-time in h + UTC UTC UTC UTC DBL d 1 + diff --git a/examples/fakedcs_cake_config.json b/examples/fakedcs_cake_config.json new file mode 100644 index 00000000..a85bb460 --- /dev/null +++ b/examples/fakedcs_cake_config.json @@ -0,0 +1,713 @@ +{ + "fakedcs:AUTACTIV": { + "channel": "fakedcs:AUTACTIV", + "gettable": true, + "settable": true, + "description": "guider-active in {no,yes}", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean", + "enumerators": { + "0": "no", + "1": "yes" + } + }, + "fakedcs:AUTGO": { + "channel": "fakedcs:AUTGO", + "gettable": true, + "settable": true, + "description": "guider-go-flag in \"\"", + "units": { + "": "string", + "formatted": "" + }, + "type": "string" + }, + "fakedcs:AUTPAUSE": { + "channel": "fakedcs:AUTPAUSE", + "gettable": true, + "settable": true, + "description": "guider-pause in \"\"", + "units": { + "": "integer", + "formatted": "" + }, + "type": "integer" + }, + "fakedcs:AUTRESUM": { + "channel": "fakedcs:AUTRESUM", + "gettable": true, + "settable": true, + "description": "guider-resume in \"\"", + "units": { + "": "integer", + "formatted": "" + }, + "type": "integer" + }, + "fakedcs:AUTRESX": { + "channel": "fakedcs:AUTRESX", + "gettable": true, + "settable": true, + "description": "guider-resumeX in arcsec", + "units": { + "": "Radians", + "formatted": "arcsec" + }, + "type": "double" + }, + "fakedcs:AUTRESY": { + "channel": "fakedcs:AUTRESY", + "gettable": true, + "settable": true, + "description": "guider-resumeY in arcsec", + "units": { + "": "Radians", + "formatted": "arcsec" + }, + "type": "double" + }, + "fakedcs:AUTSQNUM": { + "channel": "fakedcs:AUTSQNUM", + "gettable": true, + "settable": true, + "description": "guider-seqnum in \"\"", + "units": { + "": "integer", + "formatted": "" + }, + "type": "integer" + }, + "fakedcs:AUTSTOP": { + "channel": "fakedcs:AUTSTOP", + "gettable": true, + "settable": true, + "description": "guider-stop in \"\"", + "units": { + "": "integer", + "formatted": "" + }, + "type": "integer" + }, + "fakedcs:AUTXCENT": { + "channel": "fakedcs:AUTXCENT", + "gettable": true, + "settable": true, + "description": "guider-Xcent in arcsec", + "units": { + "": "Radians", + "formatted": "arcsec" + }, + "type": "double" + }, + "fakedcs:AUTYCENT": { + "channel": "fakedcs:AUTYCENT", + "gettable": true, + "settable": true, + "description": "guider-Ycent in arcsec", + "units": { + "": "Radians", + "formatted": "arcsec" + }, + "type": "double" + }, + "fakedcs:AXESTAT": { + "channel": "fakedcs:AXESTAT", + "gettable": true, + "settable": true, + "description": "axes-control-status in {unknown, not controlling, halted, in position, in limit, slewing, acquiring, tracking}", + "units": { + "": "binary", + "formatted": "" + }, + "type": "enumerated", + "enumerators": { + "0": "unknown", + "1": "not controlling", + "2": "halted", + "4": "in position", + "8": "in limit", + "16": "slewing", + "32": "acquiring", + "64": "tracking" + } + }, + "fakedcs:AZ": { + "channel": "fakedcs:AZ", + "gettable": true, + "settable": true, + "description": "telescope-azimuth in deg", + "units": { + "": "Radians", + "formatted": "deg" + }, + "type": "double" + }, + "fakedcs:CURRINST": { + "channel": "fakedcs:CURRINST", + "gettable": true, + "settable": true, + "description": "current-instrument in \"\"", + "units": { + "": "string", + "formatted": "" + }, + "type": "string" + }, + "fakedcs:EL": { + "channel": "fakedcs:EL", + "gettable": true, + "settable": true, + "description": "telescope-elevation in deg", + "units": { + "": "Radians", + "formatted": "deg" + }, + "type": "double" + }, + "fakedcs:PARANG": { + "channel": "fakedcs:PARANG", + "gettable": true, + "settable": true, + "description": "parallactic-angle-astrometric in deg", + "units": { + "": "Radians", + "formatted": "deg" + }, + "type": "double" + }, + "fakedcs:ROTBASE": { + "channel": "fakedcs:ROTBASE", + "gettable": true, + "settable": true, + "description": "rotator-base-angle in deg", + "units": { + "": "Radians", + "formatted": "deg" + }, + "type": "double" + }, + "fakedcs:ROTDEST": { + "channel": "fakedcs:ROTDEST", + "gettable": true, + "settable": true, + "description": "rotator-user-destination in deg", + "units": { + "": "Radians", + "formatted": "deg" + }, + "type": "double" + }, + "fakedcs:ROTDETS1": { + "channel": "fakedcs:ROTDETS1", + "gettable": true, + "settable": true, + "description": "rotator-destination-timestamp1-ut1-slow in \"\"", + "units": { + "": "Seconds", + "formatted": "" + }, + "type": "double" + }, + "fakedcs:ROTDETS2": { + "channel": "fakedcs:ROTDETS2", + "gettable": true, + "settable": true, + "description": "rotator-destination-timestamp2-ut1-slow in \"\"", + "units": { + "": "Seconds", + "formatted": "" + }, + "type": "double" + }, + "fakedcs:ROTERRS": { + "channel": "fakedcs:ROTERRS", + "gettable": true, + "settable": true, + "description": "rotator-error-string in \"\"", + "units": { + "": "string", + "formatted": "" + }, + "type": "string" + }, + "fakedcs:ROTERVL": { + "channel": "fakedcs:ROTERVL", + "gettable": true, + "settable": true, + "description": "rotator-error-status in \"\"", + "units": { + "": "integer", + "formatted": "" + }, + "type": "integer" + }, + "fakedcs:ROTHALT": { + "channel": "fakedcs:ROTHALT", + "gettable": true, + "settable": true, + "description": "rotator-halt-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:ROTHALTC": { + "channel": "fakedcs:ROTHALTC", + "gettable": true, + "settable": true, + "description": "rotator-halt-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:ROTINIT": { + "channel": "fakedcs:ROTINIT", + "gettable": true, + "settable": true, + "description": "rotator-initialization-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:ROTINITC": { + "channel": "fakedcs:ROTINITC", + "gettable": true, + "settable": true, + "description": "rotator-initialization-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:ROTMODE": { + "channel": "fakedcs:ROTMODE", + "gettable": true, + "settable": true, + "description": "rotator-tracking-mode in {unknown, position angle, vertical angle, stationary}", + "units": { + "": "binary", + "formatted": "" + }, + "type": "enumerated", + "enumerators": { + "0": "unknown", + "1": "position angle", + "2": "vertical angle", + "4": "stationary" + } + }, + "fakedcs:ROTNEWS": { + "channel": "fakedcs:ROTNEWS", + "gettable": true, + "settable": true, + "description": "rotator-new-destination-slow in \"\"", + "units": { + "": "integer", + "formatted": "" + }, + "type": "integer" + }, + "fakedcs:ROTPDEST": { + "channel": "fakedcs:ROTPDEST", + "gettable": true, + "settable": true, + "description": "rotator-physical-destination in deg", + "units": { + "": "Radians", + "formatted": "deg" + }, + "type": "double" + }, + "fakedcs:ROTPDSTS": { + "channel": "fakedcs:ROTPDSTS", + "gettable": true, + "settable": true, + "description": "rotator-physical-destination-slow in deg", + "units": { + "": "Radians", + "formatted": "deg" + }, + "type": "double" + }, + "fakedcs:ROTPOSN": { + "channel": "fakedcs:ROTPOSN", + "gettable": true, + "settable": true, + "description": "rotator-user-position in deg", + "units": { + "": "Radians", + "formatted": "deg" + }, + "type": "double" + }, + "fakedcs:ROTPOTS1": { + "channel": "fakedcs:ROTPOTS1", + "gettable": true, + "settable": true, + "description": "rotator-position-timestamp1-ut1-slow in \"\"", + "units": { + "": "Seconds", + "formatted": "" + }, + "type": "double" + }, + "fakedcs:ROTPOTS2": { + "channel": "fakedcs:ROTPOTS2", + "gettable": true, + "settable": true, + "description": "rotator-position-timestamp2-ut1-slow in \"\"", + "units": { + "": "Seconds", + "formatted": "" + }, + "type": "double" + }, + "fakedcs:ROTPPOSN": { + "channel": "fakedcs:ROTPPOSN", + "gettable": true, + "settable": true, + "description": "rotator-physical-position in deg", + "units": { + "": "Radians", + "formatted": "deg" + }, + "type": "double" + }, + "fakedcs:ROTSEL": { + "channel": "fakedcs:ROTSEL", + "gettable": true, + "settable": true, + "description": "rotator-select in {none, fcass (forward cass), cass (cassegrain), lbc (left bent cass), rbc (right bent cass)}", + "units": { + "": "binary", + "formatted": "" + }, + "type": "enumerated", + "enumerators": { + "0": "none", + "1": "fcass (forward cass)", + "2": "cass (cassegrain)", + "3": "lbc (left bent cass)", + "4": "rbc (right bent cass)" + } + }, + "fakedcs:ROTSPEED": { + "channel": "fakedcs:ROTSPEED", + "gettable": true, + "settable": true, + "description": "rotator-fake-speed in deg/sec", + "units": { + "": "double", + "formatted": "deg/sec" + }, + "type": "double" + }, + "fakedcs:ROTSRVER": { + "channel": "fakedcs:ROTSRVER", + "gettable": true, + "settable": true, + "description": "rotator-servo-error in arcsec", + "units": { + "": "Radians", + "formatted": "arcsec" + }, + "type": "double" + }, + "fakedcs:ROTSTAT": { + "channel": "fakedcs:ROTSTAT", + "gettable": true, + "settable": true, + "description": "rotator-state in {unknown, off, initializing, standby, tracking, fault, manual, halted, disabled, in position, slewing, acquiring, in limit}", + "units": { + "": "binary", + "formatted": "" + }, + "type": "enumerated", + "enumerators": { + "0": "unknown", + "1": "off", + "2": "initializing", + "4": "standby", + "8": "tracking", + "16": "fault", + "32": "manual", + "64": "halted", + "128": "disabled", + "256": "in position", + "512": "slewing", + "1024": "acquiring", + "2048": "in limit" + } + }, + "fakedcs:ROTSTBY": { + "channel": "fakedcs:ROTSTBY", + "gettable": true, + "settable": true, + "description": "rotator-standby-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:ROTSTBYC": { + "channel": "fakedcs:ROTSTBYC", + "gettable": true, + "settable": true, + "description": "rotator-standby-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:ROTSTST": { + "channel": "fakedcs:ROTSTST", + "gettable": true, + "settable": true, + "description": "rotator-state-string in \"\"", + "units": { + "": "string", + "formatted": "" + }, + "type": "string" + }, + "fakedcs:ROTTIME": { + "channel": "fakedcs:ROTTIME", + "gettable": true, + "settable": true, + "description": "rotator-current-time in \"\"", + "units": { + "": "Seconds", + "formatted": "" + }, + "type": "double" + }, + "fakedcs:SIMULATE": { + "channel": "fakedcs:SIMULATE", + "gettable": true, + "settable": true, + "description": "simulating-dcs in {false, true}", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean", + "enumerators": { + "0": "false", + "1": "true" + } + }, + "fakedcs:TERTERRS": { + "channel": "fakedcs:TERTERRS", + "gettable": true, + "settable": true, + "description": "tertiary-error-string in \"\"", + "units": { + "": "string", + "formatted": "" + }, + "type": "string" + }, + "fakedcs:TERTERVL": { + "channel": "fakedcs:TERTERVL", + "gettable": true, + "settable": false, + "description": "tertiary-error-status in \"\"", + "units": { + "": "integer", + "formatted": "" + }, + "type": "integer" + }, + "fakedcs:TERTHALT": { + "channel": "fakedcs:TERTHALT", + "gettable": true, + "settable": true, + "description": "tertiary-halt-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:TERTHALTC": { + "channel": "fakedcs:TERTHALTC", + "gettable": true, + "settable": true, + "description": "tertiary-halt-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:TERTINIT": { + "channel": "fakedcs:TERTINIT", + "gettable": true, + "settable": true, + "description": "tertiary-initialization-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:TERTINITC": { + "channel": "fakedcs:TERTINITC", + "gettable": true, + "settable": true, + "description": "tertiary-initialization-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:TERTMOVE": { + "channel": "fakedcs:TERTMOVE", + "gettable": true, + "settable": true, + "description": "move-tertiary in no,yes", + "units": { + "": "boolean", + "formatted": "no,yes" + }, + "type": "boolean" + }, + "fakedcs:TERTMOVEC": { + "channel": "fakedcs:TERTMOVEC", + "gettable": true, + "settable": true, + "description": "move-tertiary in no,yes", + "units": { + "": "boolean", + "formatted": "no,yes" + }, + "type": "boolean" + }, + "fakedcs:TERTDEST": { + "channel": "fakedcs:TERTDEST", + "gettable": true, + "settable": true, + "description": "tertiary-destination in {lnas, lbc1, lbc2, stowed, rbc2, rbc1, rnas, cass, mirrorup}", + "units": { + "": "binary", + "formatted": "" + }, + "type": "enumerated", + "enumerators": { + "0": "lnas", + "1": "lbc1", + "2": "lbc2", + "3": "stowed", + "4": "rbc2", + "5": "rbc1", + "6": "rnas", + "7": "cass", + "8": "mirrorup" + } + }, + "fakedcs:TERTPOSN": { + "channel": "fakedcs:TERTPOSN", + "gettable": true, + "settable": true, + "description": "tertiary-position in {lnas, lbc1, lbc2, stowed, rbc2, rbc1, rnas, unknown, cass, mirrorup}", + "units": { + "": "binary", + "formatted": "" + }, + "type": "enumerated", + "enumerators": { + "0": "lnas", + "1": "lbc1", + "2": "lbc2", + "3": "stowed", + "4": "rbc2", + "5": "rbc1", + "6": "rnas", + "7": "unknown", + "8": "cass", + "9": "mirrorup" + } + }, + "fakedcs:TERTSTAT": { + "channel": "fakedcs:TERTSTAT", + "gettable": true, + "settable": true, + "description": "tertiary-state in {unknown, off, initializing, standby, tracking, fault, manual, halted, disabled, in position, slewing, acquiring, in limit}", + "units": { + "": "binary", + "formatted": "" + }, + "type": "enumerated", + "enumerators": { + "0": "unknown", + "1": "off", + "2": "initializing", + "4": "standby", + "8": "tracking", + "16": "fault", + "32": "manual", + "64": "halted", + "128": "disabled", + "256": "in position", + "512": "slewing", + "1024": "acquiring", + "2048": "in limit" + } + }, + "fakedcs:TERTSTBY": { + "channel": "fakedcs:TERTSTBY", + "gettable": true, + "settable": true, + "description": "tertiary-standby-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:TERTSTBYC": { + "channel": "fakedcs:TERTSTBYC", + "gettable": true, + "settable": true, + "description": "tertiary-standby-command in \"\"", + "units": { + "": "boolean", + "formatted": "" + }, + "type": "boolean" + }, + "fakedcs:TERTSTST": { + "channel": "fakedcs:TERTSTST", + "gettable": true, + "settable": true, + "description": "tertiary-state-string in \"\"", + "units": { + "": "string", + "formatted": "" + }, + "type": "string" + }, + "fakedcs:UTC": { + "channel": "fakedcs:UTC", + "gettable": true, + "settable": true, + "description": "coordinated-universal-time in h", + "units": { + "": "Seconds", + "formatted": "h" + }, + "type": "double" + } +} \ No newline at end of file diff --git a/examples/parse_cake_config.py b/examples/parse_cake_config.py new file mode 100644 index 00000000..9a18ae35 --- /dev/null +++ b/examples/parse_cake_config.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +""" +Parser to convert cake files to mktl JSON config format. + +The parser extracts keyword definitions from the cake file and creates +a JSON object mapping each keyword to its read and write channels. +""" + +import json +import re +from pathlib import Path + + +CAKE_BIN2ASCII_METHODS = bin2asc_mapping = { + "R2D": { + "function": "r2d_bin2asc", + "description": "Radians to degrees", + "bin_units": "Radians" + }, + "R2D1": { + "function": "r2d_bin2asc", + "description": "Radians to degrees", + "bin_units": "Radians" + }, + "R2D2": { + "function": "r2d_bin2asc", + "description": "Radians to degrees", + "bin_units": "Radians" + }, + "RXD": { + "function": "rxd_bin2asc", + "description": "Radians to DMS", + "bin_units": "Radians" + }, + "RXH": { + "function": "rxh_bin2asc", + "description": "Radians to HMS", + "bin_units": "Radians" + }, + "RXH1": { + "function": "rxh1_bin2asc", + "description": "Radians to HMS with sign", + "bin_units": "Radians" + }, + "A2R": { + "function": "a2r_bin2asc", + "description": "Arcseconds to radians", + "bin_units": "Arcseconds" + }, + "M2A": { + "function": "m2a_bin2asc", + "description": "Meters to arcseconds (assumes f/15)", + "bin_units": "Meters" + }, + "M2A3": { + "function": "m2a_bin2asc", + "description": "Meters to arcseconds (assumes f/15)", + "bin_units": "Meters" + }, + "R2A": { + "function": "r2a_bin2asc", + "description": "Radians to arcseconds", + "bin_units": "Radians" + }, + "R2A2": { + "function": "r2a_bin2asc", + "description": "Radians to arcseconds", + "bin_units": "Radians" + }, + "R2A3": { + "function": "r2a_bin2asc", + "description": "Radians to arcseconds", + "bin_units": "Radians" + }, + "R2A4": { + "function": "r2a_bin2asc", + "description": "Radians to arcseconds", + "bin_units": "Radians" + }, + "R2S": { + "function": "r2s_bin2asc", + "description": "Radians to seconds of time", + "bin_units": "Radians" + }, + "R2S4": { + "function": "r2s_bin2asc", + "description": "Radians to seconds of time", + "bin_units": "Radians" + }, + "INT": { + "function": "int_bin2asc", + "description": "convert integer", + "bin_units": "integer" + }, + "BOO": { + "function": "boo_bin2asc", + "description": "convert boolean", + "bin_units": "boolean" + }, + "FLT": { + "function": "flt_bin2asc", + "description": "convert float", + "bin_units": "float" + }, + "DBL": { + "function": "dbl_bin2asc", + "description": "convert double", + "bin_units": "double" + }, + "DBL1": { + "function": "dbl_bin2asc", + "description": "convert double", + "bin_units": "double" + }, + "DBL2": { + "function": "dbl_bin2asc", + "description": "convert double", + "bin_units": "double" + }, + "DBL3": { + "function": "dbl_bin2asc", + "description": "convert double", + "bin_units": "double" + }, + "DBL4": { + "function": "dbl_bin2asc", + "description": "convert double", + "bin_units": "double" + }, + "M2CM": { + "function": "m2cm_bin2asc", + "description": "convert meters to centimeters", + "bin_units": "meters" + }, + "M2MM": { + "function": "m2mm_bin2asc", + "description": "convert meters to millimeters", + "bin_units": "meters" + }, + "M2UM": { + "function": "m2um_bin2asc", + "description": "convert meters to microns", + "bin_units": "meters" + }, + "STR": { + "function": "str_bin2asc", + "description": "convert string", + "bin_units": "string" + }, + "UTC": { + "function": "utc_bin2asc", + "description": "Seconds since 1970 to UT time of day (hh:mm:ss.ss)", + "bin_units": "Seconds" + }, + "DATE": { + "function": "date_bin2asc", + "description": "Seconds since 1970 to UT date (yyyy-mm-dd)", + "bin_units": "Seconds" + }, + "NOP": { + "function": "nop_bin2asc", + "description": "No conversion", + "bin_units": "" + }, + "ENM": { + "function": "enm_bin2asc", + "description": "convert binary to enumeration string", + "bin_units": "binary" + }, + "ENMM": { + "function": "enmm_bin2asc", + "description": "converts a single binary bit to the enumeration", + "bin_units": "binary" + }, + "MASK": { + "function": "mask_bin2asc", + "description": "convert binary to multiple bit mask", + "bin_units": "binary" + }, + "DT1": { + "function": "dt1_bin2asc", + "description": "rad-per-sec-to_s/hr", + "bin_units": "rad-per-sec" + }, + "DT2": { + "function": "dt2_bin2asc", + "description": "rad-per-sec-to-arcsec/hr", + "bin_units": "rad-per-sec" + } +} + + +def parse_cake_config(input_file, output_file=None): + """ + Parse a cake configuration file and convert it to JSON. + + Args: + input_file: Path to the input config file + output_file: Path to the output JSON file (optional) + + Returns: + dict: Parsed configuration as a dictionary + """ + result = {} + prefix = "" + + with open(input_file, 'r') as f: + lines = f.readlines() + + i = 0 + while i < len(lines): + line = lines[i].strip() + + # find prefix line + if line.startswith('prefix = '): + prefix = line.split('=', 1)[1].strip() + i += 1 + continue + + # Skip empty lines and comments + if not line or line.startswith('#'): + i += 1 + continue + + # Skip configuration lines like "skip_num = 0", "prefix = fakedcs:" + if '=' in line and not line.startswith(' '): + i += 1 + continue + + # Check if this is a keyword definition line (starts with whitespace and contains "in") + if line and not line.startswith('#') and ' in ' in line: + # This is a keyword definition line - collect potentially multi-line description + description_parts = [line.strip()] + + # Check if there are continuation lines (lines that don't contain mapping info) + j = i + 1 + while j < len(lines): + next_line = lines[j].strip() + # If the next line is empty, a comment, or looks like a mapping line (uppercase keyword), stop + if not next_line or next_line.startswith('#'): + break + # Check if it's a mapping line (starts with uppercase letters and has multiple columns) + parts = next_line.split() + if len(parts) >= 3 and parts[0].isupper(): + # This is the mapping line + break + # Otherwise, it's a continuation of the description + description_parts.append(next_line) + j += 1 + + # Join all description parts + full_description = ' '.join(description_parts) + + # Move to the mapping line first to check if it's ENMM type + i = j + + if i < len(lines): + mapping_line = lines[i].strip() + + # Parse the mapping line to extract the three channel names + # Format: KEYWORD READ_CHANNEL WRITE_CHANNEL BIN2ASCII_method ASCII2BIN_method TYPE ... + parts = mapping_line.split() + + if len(parts) >= 3: + keyword = parts[0].lower() # First column (lowercase) + + read_channel = prefix + parts[1] # Second column + write_channel = prefix + parts[2] # Third column + + # Check if column 4 (index 3) is ENMM to determine bitmask enumeration + is_bitmask = len(parts) >= 4 and parts[3] == 'ENMM' + + # Extract enumerators if present (inside curly braces) + enumerators = {} + enum_match = re.search(r'\{([^}]+)\}', full_description) + if enum_match: + enum_content = enum_match.group(1) + # Split by comma and clean up whitespace + enum_values = [v.strip() for v in enum_content.split(',')] + + if is_bitmask: + # For ENMM: use powers of 2 (0, 1, 2, 4, 8, 16, ...) + # First value is 0, second is 1, then powers of 2 + for idx, val in enumerate(enum_values): + if idx == 0: + key = "0" + elif idx == 1: + key = "1" + else: + key = str(2 ** (idx - 1)) + enumerators[key] = val + else: + # For ENM: use sequential indices (0, 1, 2, 3, ...) + enumerators = {str(i): val for i, val in enumerate(enum_values)} + + # Extract units from the description (after "in") + f_units = "" + if ' in ' in full_description: + units_part = full_description.split(' in ', 1)[1] + # Remove curly braces and their contents if present + f_units = re.sub(r'\{[^}]*\}', '', units_part).strip() + # If units is empty or just quotes, set to empty string + if f_units in ['""', '']: + f_units = "" + + bin2ascii_method = parts[3] if len(parts) >= 4 else "" + bin_units = "" + if bin2ascii_method in CAKE_BIN2ASCII_METHODS: + bin_units = CAKE_BIN2ASCII_METHODS[bin2ascii_method]['bin_units'] + + + # Extract type from 6th column (index 5) if available + type_code = parts[5] if len(parts) >= 6 else "" + + # Map type codes to type names + type_map = { + 'b': 'boolean', + 'i': 'integer', + 'd': 'double', + 'em': 'enumerated', + 'e': 'enumerated', + 's': 'string' + } + data_type = type_map.get(type_code, type_code) + + # Determine if we need to create separate entries + has_read = read_channel and read_channel.strip() + has_write = write_channel and write_channel.strip() + same_channel = has_read and has_write and read_channel == write_channel + + if same_channel: + # Same read/write channel - create one entry + entry = { + "channel": read_channel, + "gettable": True, + "settable": True, + "description": full_description, + "units": { + "": bin_units, + "formatted": f_units + }, + "type": data_type + } + + # Add enumerators only if they exist + if enumerators: + entry["enumerators"] = enumerators + + result[keyword] = entry + + else: + # Different read/write channels - create separate entries + if has_read: + read_entry = { + "channel": read_channel, + "gettable": True, + "settable": False, + "description": full_description, + "units": { + "": bin_units, + "formatted": f_units + }, + "type": data_type + } + + # Add enumerators only if they exist + if enumerators: + read_entry["enumerators"] = enumerators + + result[f"{keyword}:read"] = read_entry + + if has_write: + write_entry = { + "channel": write_channel, + "gettable": False, + "settable": True, + "description": full_description, + "units": { + "": bin_units, + "formatted": f_units + }, + "type": data_type + } + + # Add enumerators only if they exist + if enumerators: + write_entry["enumerators"] = enumerators + + result[f"{keyword}:write"] = write_entry + + i += 1 + + # Now we are going to merge duplicate channels (same channel name) into single entries. + # The name will be the channel name, and gettable/settable will be True if any of the duplicates had it True. + + merged_result = {} + channels = [x['channel'] for x in result.values()] + for key, entry in result.items(): + channel = entry['channel'] + if channels.count(channel) > 1: + # Duplcate channel found - merge entries + if channel in merged_result: # already merged + continue + # Find all entries with this channel + same_channel_entries = [v for v in result.values() if v['channel'] == channel] + merged_entry = { + "channel": channel, + "gettable": any(e.get('gettable', False) for e in same_channel_entries), + "settable": any(e.get('settable', False) for e in same_channel_entries), + "description": same_channel_entries[0]['description'], # take from first entry + "units": same_channel_entries[0]['units'], # take from first entry + "type": same_channel_entries[0]['type'] # take from first entry + } + merged_result[channel] = merged_entry + else: # unique channel, just copy over + merged_result[channel] = entry + + # Write to output file if specified + if output_file: + with open(output_file, 'w') as f: + json.dump(merged_result, f, indent=2) + print(f"Wrote {len(result)} entries to {output_file}") + + return merged_result + + +def main(): + """Main entry point for the parser.""" + import argparse + + parser = argparse.ArgumentParser( + description='Convert cake config file to JSON format' + ) + parser.add_argument( + 'input_file', + help='Path to the input cake config file' + ) + parser.add_argument( + '-o', '--output', + help='Path to the output JSON file (default: .json)', + default=None + ) + + args = parser.parse_args() + + # Determine output file name + if args.output is None: + input_path = Path(args.input_file) + output_file = input_path.with_suffix('.json') + else: + output_file = args.output + + # Parse and convert + result = parse_cake_config(args.input_file, output_file) + + # Print summary + print(f"\nParsed {len(result)} keywords") + print("\nFirst few entries:") + for i, (key, value) in enumerate(result.items()): + if i >= 3: + break + print(f" {key}: {value}") + + +if __name__ == '__main__': + main() diff --git a/sbin/mkd b/sbin/mkd index cdc69240..ee508253 100755 --- a/sbin/mkd +++ b/sbin/mkd @@ -83,7 +83,7 @@ def load_configuration(store, alias, filename): contents = open(filename, 'r').read() items = mktl.json.loads(contents) - mktl.config.authoritative(store, items, alias) + mktl.config.authoritative(store, alias, items) diff --git a/tests/mktl b/tests/mktl new file mode 120000 index 00000000..e33b42da --- /dev/null +++ b/tests/mktl @@ -0,0 +1 @@ +../src/mktl \ No newline at end of file