diff --git a/doc/example/heater.rst b/doc/example/heater.rst new file mode 100644 index 0000000..8eba753 --- /dev/null +++ b/doc/example/heater.rst @@ -0,0 +1,20 @@ +.. _heater_daemon: + +Daemon: heater controller +========================= + +This example is intended to demonstrate how a :class:`mktl.Daemon` might be +constructed to communicate with a hardware controller, in this case, a simple +temperature controller, which is capable of reporting a temperature value, +heater output, and allows a heater setpoint to be established. + +A typical controller would have many more commands beyond what is presented +here, but the resulting content of the daemon could be an extension of the +structure shown here. + + +heater.py +--------- + +.. literalinclude:: ../../examples/heater/heater.py + diff --git a/doc/example/precious.py b/doc/example/precious.py index 7325224..579501f 100644 --- a/doc/example/precious.py +++ b/doc/example/precious.py @@ -1,5 +1,5 @@ import mktl -import time + class Daemon(mktl.Daemon): diff --git a/doc/examples.rst b/doc/examples.rst index 5bd8ea6..fdad6ce 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -22,3 +22,4 @@ words, that ``import mktl`` will succeed. example/callback example/daemon example/daemon_unabridged + example/heater diff --git a/examples/heater/heater.py b/examples/heater/heater.py new file mode 100644 index 0000000..4da003a --- /dev/null +++ b/examples/heater/heater.py @@ -0,0 +1,310 @@ +""" This daemon connects to a fictional temperature controller. It is + structured to relay commands and publish available telemetry from + the controller. + + As a fictional example, this code has not been directly tested, and may + contain syntax errors, logical errors, and other miscellaneous problems. +""" + +import configparser +import mktl + +# This module, which does not exist, implements a simple interface to +# the hardware controller itself. + +import heatercontroller + + +class Daemon(mktl.Daemon): + + def parse_options(self, options): + """ Read in the configuration file; this daemon can't function without + some additional guidance on how it is supposed to run. This file + would define some metadata on how the controller is being used, + including system-specific names for temperature inputs and + heater outputs. + """ + + configuration_file = options.appconfig + + parser = configparser.ConfigParser() + parser.read(configuration_file) + + self.heater_config = parser + + + def describe_items(self): + """ Generate and return the description of all items handled by + this daemon. + """ + + items = dict() + + controller_number = self.heater_config.get('main', 'controller') + controller_items = self.describe_controller_items(controller_number) + + items.update(controller_items) + + try: + inputs = self.heater_config.options('inputs') + except configparser.NoSectionError: + inputs = tuple() + + try: + outputs = self.heater_config.options('outputs') + except configparser.NoSectionError: + outputs = tuple() + + for input in inputs: + prefix = self.heater_config.get('inputs', input) + items.update(self.describe_input_items(prefix)) + + for output in outputs: + prefix = self.heater_config.get('outputs', output) + items.update(self.describe_output_items(prefix)) + + return items + + + def describe_controller_items(self, controller): + """ Generation of the description of controller-wide items is broken + out here for readability. The *controller* argument is expected + to be a number. + """ + + controller = str(controller) + prefix = 'ctrl' + controller + + items = dict() + + address = prefix + 'address' + items[address] = dict() + items[address]['description'] = 'Controller IP address.' + items[address]['settable'] = False + + aux = prefix + 'aux' + items[aux] = dict() + items[aux]['description'] = 'Auxiliary command and response.' + + firmware = prefix + 'firmware' + items[firmware] = dict() + items[firmware]['description'] = 'Controller firmware revision.' + items[firmware]['settable'] = False + + status = prefix + 'status' + items[status] = dict() + items[status]['description'] = 'Controller connection status.' + items[status]['type'] = 'enumerated' + items[status]['settable'] = False + + return items + + + def describe_input_items(self, prefix): + """ Generation of the description of input-specific items is broken + out here for readability. + """ + + items = dict() + + channel = self.heater_config.get(prefix, 'input') + chi = prefix + 'chi' + items[chi] = dict() + items[chi]['description'] = 'Channel for this temperature input.' + items[chi]['settable'] = False + items[chi]['initial'] = channel + + tmp = prefix + 'tmp' + items[tmp] = dict() + items[tmp]['description'] = 'Current temperature value.' + items[tmp]['type'] = 'numeric' + items[tmp]['units'] = 'deg C' + items[tmp]['settable'] = False + + return items + + + def describe_output_items(self, prefix): + """ Generation of the description of output-specific items is broken + out here for readability. + """ + + items = dict() + + channel = self.heater_config.get(prefix, 'output') + cho = prefix + 'cho' + items[cho] = dict() + items[cho]['description'] = 'Channel for this heater output.' + items[cho]['settable'] = False + items[chi]['initial'] = channel + + out = prefix + 'out' + items[out] = dict() + items[out]['description'] = 'Current heater output.' + items[out]['type'] = 'numeric' + items[out]['units'] = 'watts' + items[out]['settable'] = False + + trg = prefix + 'trg' + items[trg] = dict() + items[trg]['description'] = 'Heater setpoint/target value.' + items[trg]['type'] = 'numeric' + items[trg]['units'] = 'deg C' + + return items + + + def setup(self): + + controller = heatercontroller.Controller(self.heater_config) + + controller_number = self.heater_config.get('main', 'controller') + self.setup_controller_items(controller_number, controller) + + try: + inputs = self.heater_config.options('inputs') + except configparser.NoSectionError: + inputs = tuple() + + try: + outputs = self.heater_config.options('outputs') + except configparser.NoSectionError: + outputs = tuple() + + for input in inputs: + prefix = self.heater_config.get('inputs', input) + self.setup_input_items(prefix, controller) + + for output in outputs: + prefix = self.heater_config.get('outputs', output) + self.setup_output_items(prefix, controller) + + + def setup_controller_items(self, number, controller): + + number = str(number) + prefix = 'ctrl' + number + + aux = prefix + 'aux' + + self.add_item(AuxiliaryCommand, aux, controller) + + + def setup_input_items(self, prefix, controller): + + tmp = prefix + 'tmp' + self.add_item(InputTemperature, tmp, controller) + + + def setup_output_items(self, prefix, controller): + + out = prefix + 'out' + self.add_item(OutputPower, out, controller) + + trg = prefix + 'trg' + self.add_item(OutputSetpoint, trg, controller) + + +class ControllerItem(mktl.Item): + + def __init__(self, store, key, controller): + + mktl.Item.__init__(self, store, key) + self.controller = controller + + # The custom item subclasses defined here all manage published values + # via some mechanism other than the default publish-on-set behavior. + + self.publish_on_set = False + + +class AuxiliaryCommand(ControllerItem): + + def perform_set(self, value): + + self.value = value + response = self.controller.command(value) + self.value = value + ' => ' + response + + +class InputChannel(ControllerItem): + + def __init__(self, *args, **kwargs): + ControllerItem.__init__(self, *args, **kwargs) + + channel = self.key[:-3] + 'chi' + channel = self.store[channel] + self.channel = channel + + self.poll(0.5) + + + def perform_get(self): + + channel = self.channel.value + command = 'TEMP? ' + channel + value = self.controller.command(command) + + return value + + +class OutputPower(ControllerItem): + + def __init__(self, *args, **kwargs): + ControllerItem.__init__(self, *args, **kwargs) + + channel = self.key[:-3] + 'cho' + channel = self.store[channel] + self.channel = channel + + self.poll(0.5) + + + def perform_get(self): + + channel = self.channel.value + command = 'OUT? ' + channel + value = self.controller.command(command) + + return value + + +class OutputSetpoint(ControllerItem): + + def __init__(self, *args, **kwargs): + ControllerItem.__init__(self, *args, **kwargs) + + channel = self.key[:-3] + 'cho' + channel = self.store[channel] + self.channel = channel + + # The setpoint is not expected to change absent commands, so the + # polling rate here is lower than for the temperature and power + # outputs. But the controller is still considered the authoritative + # source of the setpoint value. + + self.poll(10) + + + def perform_get(self): + + channel = self.channel.value + command = 'SETP? ' + channel + value = self.controller.command(command) + + return value + + + def perform_set(self, value): + + channel = self.channel.value + command = 'SETP ' + channel + ' ' + str(value) + self.controller.command(command) + + # Read the current setpoint back from the controller rather than + # assume it matches the value commanded. + + self.req_poll() + + +# vim: set expandtab tabstop=8 softtabstop=4 shiftwidth=4 autoindent: diff --git a/examples/KTL.py b/examples/ktl/KTL.py similarity index 100% rename from examples/KTL.py rename to examples/ktl/KTL.py diff --git a/examples/ktl2mktl b/examples/ktl/ktl2mktl similarity index 100% rename from examples/ktl2mktl rename to examples/ktl/ktl2mktl diff --git a/sbin/mkd b/sbin/mkd index 282fb2a..409f484 100755 --- a/sbin/mkd +++ b/sbin/mkd @@ -83,7 +83,7 @@ def load_catalog(store, alias, filename): contents = open(filename, 'r').read() items = mktl.json.loads(contents) - mktl.config.authoritative(store, alias, items) + mktl.meta.authoritative(store, alias, items) diff --git a/src/mktl/daemon.py b/src/mktl/daemon.py index 03442c4..52bcf60 100644 --- a/src/mktl/daemon.py +++ b/src/mktl/daemon.py @@ -63,6 +63,23 @@ def __init__(self, store, alias, override=False, options=None): self.cleanup = self._cleanup_wrapper self.shutdown = threading.Event() + # Allow subclasses to parse their own configuration file prior to + # describing or substantiating any items. + + self.parse_options(self.options) + + + # Allow subclasses to provide a generated dictionary of item + # descriptions; this hook needs to be exercised prior to any + # queries for the authoritative UUID, since it's possible this + # may be the first (and only) declaration of meta.authoritative() + # that occurs for a given daemon. + + generated = self.describe_items() + if generated: + meta.authoritative(store, alias, generated) + + self.catalog = meta.catalog(store, alias) self.uuid = self.catalog.authoritative_uuid @@ -262,11 +279,13 @@ def add_handlers(self, handlers): self.add_handler(key, request, method) - def add_item(self, item_class, key, **kwargs): + def add_item(self, item_class, key, *args, **kwargs): """ Add an :class:`mktl.Item` to this daemon instance; this is the entry point for establishing an authoritative item, one that will handle - inbound get/set request and the like. The *kwargs* will be passed - directly to the *item_class* when it is called to be instantiated. + inbound get/set request and the like. The *args* and *kwargs* will + be passed directly to the *item_class* when it is called to be + instantiated; note that a default :class:`mktl.Item` takes no + additional arguments, this is only meaningful for custom subclasses. """ key = key.lower() @@ -302,9 +321,9 @@ def add_item(self, item_class, key, **kwargs): preserved_callbacks = tuple() - kwargs['authoritative'] = True - kwargs['pub'] = self.pub - created = item_class(self.store, key, **kwargs) + created = item_class(self.store, key, *args, **kwargs) + created._authoritative(self.pub) + created.subscribe(prime=False) # Instantiating the item results in a persistent reference in # self.store._items, there is no need to manipulate that dictionary @@ -463,6 +482,55 @@ def _cleanup_wrapper(self, *args, **kwargs): return self._cleanup(*args, **kwargs) + def describe_items(self): + """ Subclasses should override the :func:`describe_items` method to + supplement the daemon's authoritative catalog block with any + items whose description is generated at run time, as opposed to + loaded from a file. + + The sole return argument is a dictionary, keyed by mKTL item + keys, and the value is another dictionary containing the fields + of the item description. + + For example:: + + items = dict() + + items['watermelons'] = dict() + items['watermelons']['description'] = 'Quantity of watermelons.' + items['watermelons']['units'] = 'melons' + items['watermelons']['persist'] = True + items['watermelons']['type'] = 'numeric' + items['watermelons']['format'] = "%d" + + return items + + The default implementation of this method takes no actions. + """ + + return None + + + def parse_options(self, options): + """ Subclasses should override the :func:`parse_options` method to + interpret the :py:attr:`options` attribute, which is a + :class:`argparse.ArgumentParser` instance, and is passed as an + argument to this method for convenience. This method is called + early in the initialization of the Daemon, allowing the subclass + to establish its local configuration prior to describing or + instantiating any items. + + For example, the options.appconfig attribute may indicate the + location of a configparser-formatted configuration file. A subclass + would use this method to read the file, parse its contents, and + establish local variables for subsequent use by other methods. + + The default implementation of this method takes no actions. + """ + + pass + + def setup(self): """ Subclasses should override the :func:`setup` method to invoke :func:`add_item` for any custom :class:`mktl.Item` subclasses diff --git a/src/mktl/item.py b/src/mktl/item.py index 2089504..f82b4c9 100644 --- a/src/mktl/item.py +++ b/src/mktl/item.py @@ -34,17 +34,18 @@ class Item: untruths = set((None, False, 0, 'false', 'f', 'no', 'n', 'off', 'disable', '')) - def __init__(self, store, key, subscribe=True, authoritative=False, pub=None): + def __init__(self, store, key): - self.authoritative = authoritative key = key.lower() - self.key = key - self.full_key = store.name + '.' + key - self.store = store - self.description = store.catalog[key] + + self.authoritative = False self.callbacks = list() + self.description = store.catalog[key] + self.full_key = store.name + '.' + key + self.key = key self.log_on_set = True self.publish_on_set = True + self.store = store self.subscribed = False self.timeout = 120 @@ -53,7 +54,7 @@ def __init__(self, store, key, subscribe=True, authoritative=False, pub=None): self._daemon_value = None self._daemon_value_timestamp = None - self.pub = pub + self.pub = None self.sub = None self.req = None self.rep = None @@ -116,14 +117,6 @@ def __init__(self, store, key, subscribe=True, authoritative=False, pub=None): if gettable == False: self.req_get = self.reject_get - if subscribe == True: - if self.authoritative == True: - prime = False - else: - prime = True - - self.subscribe(prime=prime) - def add_get_performer(self, method): """ Assign a method that will be called for all GET requests for this @@ -178,6 +171,17 @@ def add_set_performer(self, method): self.perform_set = method + def _authoritative(self, pub): + """ This method is invoked by a :class:`Daemon` instance in its + :func:`Daemon.add_item` method. The changes made here are + what distinguishes a client-facing item from the authoritative + daemon variant. + """ + + self.pub = pub + self.authoritative = True + + def _cleanup(self): """ Shut down any background processing involved with this item. In the general case this is not required; :class:`Item` instances diff --git a/src/mktl/store.py b/src/mktl/store.py index 35ebe47..a459d76 100644 --- a/src/mktl/store.py +++ b/src/mktl/store.py @@ -76,6 +76,14 @@ def __getitem__(self, key): # step in its initialization process, there is no need to manipulate # it directly. + # All Item instances instantiated here are client-facing, and expect + # to be subscribed to broadcast events by default. This used to + # occur at the tail end of Item.__init__(), but there is variance + # in how that should be handled depending on whether the item is + # authoritative. + + item.subscribe() + return item diff --git a/tests/unitdaemon.py b/tests/unitdaemon.py index 98df7db..1b2ed3e 100644 --- a/tests/unitdaemon.py +++ b/tests/unitdaemon.py @@ -4,79 +4,71 @@ """ import mktl -import time class Daemon(mktl.Daemon): - def __init__(self, store, alias, *args, **kwargs): + def describe_items(self): - items = generate_catalog() - mktl.meta.authoritative(store, alias, items) - mktl.Daemon.__init__(self, store, alias, *args, **kwargs) - - -# end of class Daemon + items = dict() + items['angle'] = dict() + items['angle']['description'] = 'An angular numeric item.' + items['angle']['type'] = 'numeric' + items['angle']['format'] = '%2d:%2.2d:%04.1f' + items['angle']['units'] = {'': 'radians', 'formatted': 'degrees'} + items['angle']['initial'] = 0.018049613347708025 + items['boolean'] = dict() + items['boolean']['description'] = 'A boolean item without enumerators.' + items['boolean']['type'] = 'boolean' -def generate_catalog(): + items['enumerated'] = dict() + items['enumerated']['description'] = 'An enumerated item.' + items['enumerated']['type'] = 'enumerated' + items['enumerated']['enumerators'] = {0: 'Zero', 1: 'One', 4: 'Four'} - items = dict() + items['hourangle'] = dict() + items['hourangle']['description'] = 'An angular numeric item, in h:m:s.' + items['hourangle']['type'] = 'numeric' + items['hourangle']['format'] = '%2d:%2.2d:%04.1f' + items['hourangle']['units'] = {'': 'radians', 'formatted': 'hours'} + items['hourangle']['initial'] = 0.2707442002156204 - items['angle'] = dict() - items['angle']['description'] = 'An angular numeric item.' - items['angle']['type'] = 'numeric' - items['angle']['format'] = '%2d:%2.2d:%04.1f' - items['angle']['units'] = {'': 'radians', 'formatted': 'degrees'} - items['angle']['initial'] = 0.018049613347708025 + items['mask'] = dict() + items['mask']['description'] = 'A mask item.' + items['mask']['type'] = 'mask' + items['mask']['enumerators'] = {'None': 'none set', 0: 'A', 1: 'B', 2: 'C'} - items['boolean'] = dict() - items['boolean']['description'] = 'A boolean item without enumerators.' - items['boolean']['type'] = 'boolean' + items['noyes'] = dict() + items['noyes']['description'] = 'A boolean item with enumerators.' + items['noyes']['type'] = 'boolean' + items['noyes']['enumerators'] = {0: 'No', 1: 'Yes'} - items['enumerated'] = dict() - items['enumerated']['description'] = 'An enumerated item.' - items['enumerated']['type'] = 'enumerated' - items['enumerated']['enumerators'] = {0: 'Zero', 1: 'One', 4: 'Four'} + items['number'] = dict() + items['number']['description'] = 'A numeric item.' + items['number']['type'] = 'numeric' + items['number']['units'] = 'meaningless units' - items['hourangle'] = dict() - items['hourangle']['description'] = 'An angular numeric item, in h:m:s.' - items['hourangle']['type'] = 'numeric' - items['hourangle']['format'] = '%2d:%2.2d:%04.1f' - items['hourangle']['units'] = {'': 'radians', 'formatted': 'hours'} - items['hourangle']['initial'] = 0.2707442002156204 + items['readonly'] = dict() + items['readonly']['description'] = 'A read-only numeric item.' + items['readonly']['type'] = 'numeric' + items['readonly']['units'] = 'meaningless units' + items['readonly']['initial'] = 13 + items['readonly']['settable'] = False - items['mask'] = dict() - items['mask']['description'] = 'A mask item.' - items['mask']['type'] = 'mask' - items['mask']['enumerators'] = {'None': 'none set', 0: 'A', 1: 'B', 2: 'C'} + items['string'] = dict() + items['string']['description'] = 'A string item.' + items['string']['type'] = 'string' - items['noyes'] = dict() - items['noyes']['description'] = 'A boolean item with enumerators.' - items['noyes']['type'] = 'boolean' - items['noyes']['enumerators'] = {0: 'No', 1: 'Yes'} + items['typeless'] = dict() + items['typeless']['description'] = 'A typeless item.' - items['number'] = dict() - items['number']['description'] = 'A numeric item.' - items['number']['type'] = 'numeric' - items['number']['units'] = 'meaningless units' + return items - items['readonly'] = dict() - items['readonly']['description'] = 'A read-only numeric item.' - items['readonly']['type'] = 'numeric' - items['readonly']['units'] = 'meaningless units' - items['readonly']['initial'] = 13 - items['readonly']['settable'] = False - items['string'] = dict() - items['string']['description'] = 'A string item.' - items['string']['type'] = 'string' - - items['typeless'] = dict() - items['typeless']['description'] = 'A typeless item.' +# end of class Daemon - return items # vim: set expandtab tabstop=8 softtabstop=4 shiftwidth=4 autoindent: