PK!4y}}thea/__init__.py"""Top-level package for Thea This module import attributes of Thea. Everything made explicitly available via `__all__` can be considered as part of the Thea API. """ # flake8: noqa E402 # Get metadata before logger setup so it can be included in logs. import pkg_resources try: __version__ = pkg_resources.get_distribution(__package__).version except pkg_resources.DistributionNotFound: __version__ = "dev" # Setup logger before anything else. Necessary because imported sub-modules # import logger from the package level. from . import logging_setup logger = logging_setup.main_logger() # Now the logger is setup import the public objects from .thea_world import TheaWorld from .cli import cli_app from . import exceptions from .mqtt_hardware_module import cli_mqtt_hw_module, MQTTHardwareModule # Define public API __all__ = [ "cli_app", # cli-app "cli_mqtt_hw_module" # cli hardware module "exceptions", # Thea specific exceptions "TheaWorld", # Thea base class exposing all functionalities "MQTTHardwareModule", # Mqtt moule base class exposing all functionalities ] PK! Sggthea/__main__.py"""Starts command line interface.""" import thea if __name__ == "__main__": thea.cli_app() PK!@7thea/base_itemstore.pyimport warnings from collections import namedtuple from string import Template from .exceptions import NameNotAvailable from . import logger # Type definition for BaseItem, used for tempting and testing only BaseType = namedtuple("BaseType", ["default_properties"]) BASE_TYPES = {} BASE_TYPES["Empty"] = BaseType(default_properties={}) # Name for creating an item without a specific name BASEITEM_NAME_TEMPLATE = Template("$type_$number") class BaseItem: """Object that can be stored in BaseStore.""" # Define attributes that can be changed when inheriting this class type_dict = BASE_TYPES def __init__(self, type_: str, name: str, **kwargs) -> None: """Creates a new BaseItem instance.""" # Set required type and name self.type_ = type_ self.name = name # Add any other attributes defined in _additional_init kwargs = self._additional_attributes(**kwargs) # Overwrite default properties with passed properties self.properties = self.type_dict[self.type_].default_properties self.properties.update(**kwargs) # Handle remaining setup self._additional_init() logger.debug(f"Created or loaded {self}.") def __repr__(self) -> str: return f'An instance of {self.__class__.__name__} with type: "{self.type_}", name: "{self.name}" and {len(self.properties)} properties' def saveable_format(self) -> dict: """Returns a packable `dict` that can directly be passed into the init method to reinstanciate this object.""" saveable_format = {} saveable_format["type_"] = self.type_ saveable_format["name"] = self.name saveable_format["properties"] = self.properties saveable_format = {**saveable_format, **self._additional_saveable()} return saveable_format def _additional_attributes(self, **kwargs) -> dict: """Handles setting attributes not defined in the BaseItem class""" return kwargs def _additional_saveable(self) -> dict: """Handles saving attributes not defined in the BaseItem class""" saveable_format = {} return saveable_format def _additional_init(self) -> None: """Handles additional init after properties are set.""" pass class BaseStore: """A class that stores BaseItems by type.""" # Define attributes that can be changed when inheriting this class item_to_create = BaseItem name_template = BASEITEM_NAME_TEMPLATE def __init__(self, items=[]): """Creates a new instance of this class.""" # TODO make items private self.items = {} for saveable_item in items: self.new(**saveable_item) logger.debug(f"Created or loaded a {self}.") def __repr__(self): """Returns representation including number of stored items.""" return f"An instance of {self.__class__.__name__} with {len(self)} items" def __len__(self): """Returns the number of items stored.""" return len(self.get()) def saveable_format(self) -> dict: """Returns a pickable `dict` that can directly be passed into the init method to reinstanciate this object.""" saveable_items = [] for item in self.get(): saveable_item = item.saveable_state() saveable_items.append(saveable_item) saveable_state = {} saveable_state["items"] = saveable_items return saveable_state def _name_available(self, name: str) -> bool: """Returns `True` if the name is available.""" for type_group, items in self.items.items(): for item in items: if name == item.name: return False return True def _generate_name(self, type_: str) -> str: """Returns the name with lowest available number.""" counter = 0 generated_name = self.name_template.substitute(type_=type_, number=counter) while not self._name_available(generated_name): generated_name = self.name_template.substitute(type_=type_, number=counter) counter += 1 return generated_name def new(self, type_: str, **kwargs) -> None: """Create new item""" # Set name if none has been set and check if name is valid try: name = kwargs["name"] if self._name_available(name): kwargs.pop("name") else: raise NameNotAvailable(f'The name "{name}" is not available.') except KeyError: name = self._generate_name(type_) # Create item and add to items new_item = self.item_to_create(type_, name, **kwargs) # If type is not a key of stored items create it if type_ not in self.items: self.items[type_] = [] # Store new item self.items[type_].append(new_item) def edit(self, name: str, **kwargs) -> None: """Edit item with `name`.""" for type_group, items in self.items.items(): for item in enumerate(items): if item.name == name: # Get copy of old thing old_item = item.saveable_format() # Delete old thing self.delete(name) # Create new thing old_item.update(kwargs) self.new(**old_item) logger.info(f"Edited {name} with {len(kwargs)-1} updated values.") return raise KeyError(f"{name} could not be found in {self}.") def delete(self, name: str) -> None: """Remove value with `name` from the the store.""" for type_group, items in self.items.items(): for i, item in enumerate(items): if item.name == name: del self.items[type_group][i] return raise KeyError(f"{name} could not be found in {self}.") def get(self, type_=None, name=None, single_item=False) -> list: """Returns a list of all items matching the query.""" # TODO make options output_list = [] if type_ is None and name is None: for type_group, items in self.items.items(): for item in items: output_list.append(item) elif type_ is not None and name is None: output_list = self.items.get(type_, []) elif name is not None: for type_group, items in self.items.items(): for item in items: if item.name == name and (type_ is None or type_ == type_group): output_list = [item] if len(output_list) == 0 and (type_ is not None or name is not None): raise KeyError(f"Could not find a match for query.") elif single_item is True and len(output_list) == 1: return output_list[0] elif single_item is True and len(output_list) != 1: raise warnings.warn("Only returned first item matching your query.") else: return output_list PK!^ thea/cli.pyimport argparse from .thea_world import TheaWorld from . import logger from . import logging_setup def cli_main(): """Function to try out common Thea commands.""" # Setup new Thea Wrapper tw = TheaWorld() tw.new("test world") tw.save("hi") tw.load("hi.tw") # Add some things for i in range(0, 5): tw.things.new(type_="shop") # Show the things print(tw.things.get()) # Add a communicator tw.communicators.new(type_="mqtt") # Connect the communicator # comm = tw.communicators.get(name='mqtt0', single_item=True) # print(comm.status) # comm.connect() # print(comm.status) while True: # Update environment tw.update() tw.environment.print() def cli_app(): """Handles initial argument to start main.""" logger.info("Started the Thea command-line application.") parser = argparse.ArgumentParser(description="Start Thea.") parser.add_argument( "-v", "--verbose", help="Verbose printing.", action="store_true" ) args = parser.parse_args() # Set verbosity level of logger logging_setup.vebosity(args.verbose) # Start main cli_main() if __name__ == "__main__": cli_app() PK!Tn thea/comm_handlers/__init__.py"""Sub-package containing communication handlers.""" from .mqtt import mqtt from .mqtt_constants import MQTT_PORT, MQTT_ADRESS __all__ = ["mqtt", "MQTT_PORT", "MQTT_ADRESS"] PK!Tiy &thea/comm_handlers/mosquitto_broker.py"""Module for starting and stopping the Mosquito mqtt broker""" # TODO: re-factor so with open can be used on it import subprocess import time import atexit from ..exceptions import MQTTBrokerError, MQTTBrokerPortNotAvailible from .. import logger START_BROKER_TIMEOUT = 5 # seconds MOSQUITTO_PORT_IN_USE = "Error: Only one usage of each socket address (protocol/network address/port) is normally permitted." MOSQUITTO_STARTED = "Opening ipv4 listen socket on port" def start_mqtt_broker(port): # Start the broker process broker_process = subprocess.Popen( ["mosquitto", "-p", str(port), "-v"], stderr=subprocess.PIPE, bufsize=1 ) # Ensure the process is killed at exit atexit.register(stop_mqtt_broker, broker_process) # TODO: check if this is for the current process or the whole program # Wrap in try/except to ensure the process gets killed if an exception occurs. try: # Timeout if the broker does not respond with a success message start_time = time.time() while (time.time() - start_time) < START_BROKER_TIMEOUT: # Get latest message error_message = read_broker_stderr(broker_process) # Look for port error if MOSQUITTO_PORT_IN_USE in error_message: raise MQTTBrokerPortNotAvailible( f"Could not start MQTT broker, port {port} already in use." ) elif MOSQUITTO_STARTED in error_message: logger.info(f"Started MQTT broker at port {port}.") return broker_process # Catch all other errors and raise exception elif "Error" in error_message: raise MQTTBrokerError( f'MQTT broker exited with message: "{error_message}"' ) # TODO: add timeout for starting the broker raise MQTTBrokerError( f"Starting MQTT broker timed-out after {START_BROKER_TIMEOUT} seconds." ) except Exception as some_exception: # Kill broker if an exception is not handled stop_mqtt_broker(broker_process) # Re-raise Exception raise some_exception from None def read_broker_stderr(broker_process): line = broker_process.stderr.readline() line = line.replace(b"\n", b"").replace(b"\r", b"") if line != b"": line = line.decode() logger.debug(f'Message outputted by MQTT broker: "{line}"') return line def stop_mqtt_broker(broker_process): """Kills broker process.""" broker_process.terminate() broker_process.kill() logger.info("Killed broker process.") def broker_status(broker_process): """Returns a `bool` indicating if the broker process is running.""" if broker_process.poll() is None: return True else: return False PK!~NB??thea/comm_handlers/mqtt.py"""A comm_handler is a function that handles communication to hardware A comm handling function has the following requirements/characteristics: - It will always be run in a separate process. - It starts out by connecting to its targets. - It terminates the processes if a connection could not be established. - It processes messages from a Queue object. - A message is a tuple of an address and data. - First argument is the message queue - Other arguments are passes as kwargs and should be defined in communicator_types.py How is new hardware handled?? How to handle a failed message that has already been removed from the queue?? """ import pickle from random import randint import paho.mqtt.client as paho_mqtt from .mosquitto_broker import start_mqtt_broker, stop_mqtt_broker, broker_status from .. import logger # Create random client name MQTT_CLIENT_NAME = f"thea_mainapp_{randint(0, 100000000):08d}" def on_connect(): """On connect the client subscribes to the new hardware topic.""" # TODO pass def on_message(): """Handle received message""" # TODO: filter for new hardware topic pass def mqtt(message_queue, mqtt_port): # Start the broker broker_process = start_mqtt_broker(mqtt_port) # Create client object mqtt_client = paho_mqtt.Client(MQTT_CLIENT_NAME) # Add port as attribute for logging mqtt_client.port = mqtt_port # Assign event handlers # mqtt_client.on_message = on_message mqtt_client.enable_logger(logger=logger) mqtt_client.connect("127.0.0.1", mqtt_port) # While the broker is running while broker_status(broker_process): mqtt_client.loop() # Get oldest item from message queue adress, data = message_queue.get() # TODO: Get topic to send the message to topic = adress # Publish pickled data mqtt_client.publish(topic, pickle.dumps(data)) # Disconnect client and shutdown broker mqtt_client.disconnect() stop_mqtt_broker(broker_process) PK!AwP$thea/comm_handlers/mqtt_constants.py"""Constants used by mqtt modules.""" MQTT_PORT = 1185 MQTT_ADRESS = "127.0.0.1" MQTT_REQUEST_CONFIG_TOPIC = "+/request_config" MQTT_SUPPLY_CONFIG_TOPIC = "+/supply_config" MQTT_STATUS_TOPIC = "+/status" PK!<+ %thea/comm_handlers/mqtt_new_config.py"""Temporary file for generating a configuration for a hardware module.""" import paho.mqtt.client as mqtt import uuid import pickle import time from mqtt_constants import ( MQTT_ADRESS, MQTT_PORT, MQTT_REQUEST_CONFIG_TOPIC, MQTT_SUPPLY_CONFIG_TOPIC, ) ############################################################################## from collections import namedtuple # type is a functional output type topicConfig = namedtuple("topicConfig", ["type_", "endpoints", "properties"]) # key is the topic, item is an instance of topic configuration CONFIGURATION_EXAMPLE = { "topic1": topicConfig("switch", [1, 2, 3], {}), "topic2": topicConfig("blink", [0, 5], {"interval": 5}), "topic3": topicConfig("switch", [4], {}), } ############################################################################# def supply_config_callback(client, userdata, message): """Handles a new config request""" module_identifier = message.topic.split("/")[0] print(f"Received new configuration request from {module_identifier}.") # Example module configuration new_module_configuration = CONFIGURATION_EXAMPLE # Publish new configuration client.publish( MQTT_SUPPLY_CONFIG_TOPIC.replace("+", module_identifier), pickle.dumps(new_module_configuration), ) def on_log_callback(client, userdata, level, buf): print("log: ", buf) # Create unique identifier unique_identifier = f"controller_{uuid.getnode()}" print(f'Unique identifier is set to "{unique_identifier}"') # Create MQTT client client = mqtt.Client(unique_identifier) client.message_callback_add(MQTT_REQUEST_CONFIG_TOPIC, supply_config_callback) # Connect client.connect(MQTT_ADRESS, MQTT_PORT) # Subscribe to new config request topic client.subscribe(MQTT_REQUEST_CONFIG_TOPIC) client.on_log = on_log_callback # client.enable_logger(logger=None) # Run the loop client.loop_start() time.sleep(10000) client.loop_stop() PK!ʈthea/communicator_types.pyfrom collections import namedtuple from . import comm_handlers # Values of the named tuple need to be of type: any, list of types, dict, callable CommunicatorType = namedtuple( "CommunicatorType", ["comm_handler", "default_properties"] ) # A contradictory of all ThingTypes COMMUNICATOR_TYPES = {} COMMUNICATOR_TYPES["mqtt"] = CommunicatorType( comm_handler=comm_handlers.mqtt, default_properties={"mqtt_port": comm_handlers.MQTT_PORT}, ) PK! thea/communicators.py"""Module with classes used to communicate with hardware. """ from multiprocessing import Queue, Process from .base_itemstore import BaseItem, BaseStore from .communicator_types import COMMUNICATOR_TYPES from .exceptions import CommNotConnectedError from . import logger class Communicator(BaseItem): """A communicator is a object that connects with hardware.""" # Define attributes that should be changed when inheriting this class type_dict = COMMUNICATOR_TYPES def _additional_attributes(self, **kwargs) -> dict: """Handles setting attributes not defined in the BaseItem class""" # Set initial status self.message_queue = Queue(maxsize=20) # Create (MQTT) communicator daemon process self.comm_handler = self.type_dict[self.type_].comm_handler return kwargs def _additional_init(self): """Setup MQTT comm handler""" self.comm_handler = Process( target=self.comm_handler, args=(self.message_queue, *self.properties), daemon=True, ) @property def status(self): """Returns weather the comm handling process is running.""" return self.comm_handler.is_alive() def send_message(self, adress, data): """Adds message to the message queue to be send by the daemon communicator process.""" if self.status is False: raise CommNotConnectedError(f"{self} is not connected.") else: self.message_queue.put((adress, data)) def connect(self): """Sets-up the connection in a daemon process.""" if self.status is False: # Start daemon process self.comm_handler.start() # TODO: Wait for confirmation of connection # TODO: Status is retried from the thread def disconnect(self): """Disconnect and shutdown the daemon process.""" if self.status is True: # TODO: add some time to clear the message queue no_unsend_messages = len(self.message_queue) # Kill daemon process self.comm_handler.terminate() # Flush the queue while not self.message_queue.empty(): self.message_queue.get() logger.info( f"Disconnected {self} and deleted {no_unsend_messages} unsent messages." ) class CommunicatorStore(BaseStore): item_to_create = Communicator PK!rxthea/dev_mqtt_entrypoint.py"""Temporary module for developing mqtt comm handler""" from multiprocessing import Queue from . import comm_handlers queue = Queue(10) queue.put(("address", "data")) comm_handlers.mqtt(queue, comm_handlers.MQTT_PORT) PK!2thea/env_thing_linker.py"""Updates the value of things using the thing updater informed by environment variables.""" from .thing_types import THING_TYPES class EnvThingsLinker: """Handles linking of things and things updater's.""" @staticmethod def update(things_by_type, env_variables): """Updates all types using their matching updater.""" # Loop over all types # TODO stop using .items() but a native implementation for thing_type, things in things_by_type.items(): # Retrieve updater from thing definition and pass it all # things matching the type as well as all env variables. THING_TYPES[thing_type].updater( things_of_single_type=things, **env_variables ) PK!b+ CCthea/environment/__init__.pyfrom .environment import Environment __all__ = ["Environment"] PK!=:thea/environment/default.pyimport arrow from . import updaters # Enviroment variables ENV_VARIABLES = {} ENV_VARIABLES["datetime"] = arrow.get( 2018, 1, 1, 12, 0, 0, 0, tzinfo="Europe/Amsterdam" ) # Enviroment settings ENV_SETTINGS = {} ENV_SETTINGS["time_factor"] = 10000 ENV_SETTINGS["country"] = "netherlands" ENV_SETTINGS["latitude"] = 52.3 ENV_SETTINGS["longitude"] = 4.8 ENV_SETTINGS["altitude"] = -2 # Shop model shops_model = updaters.StackedLinearModel("day") shops_model.add_point(arrow.get("07:00", "HH:mm"), 0) shops_model.add_point(arrow.get("10:00", "HH:mm"), 0.9) shops_model.add_point(arrow.get("17:00", "HH:mm"), 0.9) shops_model.add_point(arrow.get("22:00", "HH:mm"), 0) ENV_SETTINGS["shops_model"] = shops_model PK!7thea/environment/defenitions.py# Shops Model: StackedLinearModel with 4 datapoints # --------------------- # Datetime: 2018-11-04T08:43:37.208913+00:00 # Temperature: 12 # Pressure: None # Season: Fall # Workday: False # Holiday: None # Shops Open: 0.52 # Apparent Solar Zenith: 76.03 # Solar Zenith: 76.09 # Apparent Solar Elevation: 13.97 # Solar Elevation: 13.91 # Solar Azimuth: 140.14 # Equation Of Time: 16.45 # Clear Sky Global Horizontal Irradiance: 185.33 # Clear Sky Direct Normal Irradiance: 579.49 # Clear Sky Diffuse Horizontal Irradiance: 45.41 # Solar Wind Direction: SE """Definitions for the environment settings and variables.""" from collections import namedtuple """Environment setting""" EnvSetting = namedtuple( "EnvSetting", ["name", "long_unit", "short_unit", "allowed", "default", "help_"] ) ENV_SETTINGS_DEF = {} ENV_SETTINGS_DEF["time_factor"] = EnvSetting( name="Time-factor", long_unit="times", short_unit="x", allowed=range(1, 100_000), default=1000, help_="Factor time will be sped up.", ) ENV_SETTINGS_DEF["altitude"] = EnvSetting( name="Altitude", long_unit="meters", short_unit="m", allowed=range(-100, 2000), default=0, help_="Altitude of the location in meters.", ) ENV_SETTINGS_DEF["latitude"] = EnvSetting( name="Latitude", long_unit="degrees", short_unit="°", allowed=range(-90, 90), default=52.3, help_="Latitude of the location in degrees.", ) ENV_SETTINGS_DEF["longitude"] = EnvSetting( name="Longitude", long_unit="degrees", short_unit="°", allowed=range(-180, 180), default=4.8, help_="Longitude of the location in degrees.", ) ENV_SETTINGS_DEF["country"] = EnvSetting( name="Country", long_unit="", short_unit="", allowed=str, default="NL", help_="ISO 3166 2-character country code.", ) """Environment variables""" EnvVariable = namedtuple("EnvVariable", ["unit", "help_"]) ENV_VARIABLES_DEF = {} ENV_VARIABLES_DEF["temperature"] = EnvVariable( name="Temperature", long_unit="degrees Celsius", short_unit="°C", help_="Temperature in degrees Celsius.", ) PK!C֝thea/environment/environment.pyimport arrow from .default import ENV_SETTINGS, ENV_VARIABLES from ..pretty_printing import pretty_string, pretty_dict from . import updaters from .. import logger class Environment: def __init__(self, variables={}, settings={}) -> None: """Overwrites default values with passed values.""" # Variables for updating the clock self._last_update = arrow.utcnow() self._real_update_rate = 0 self._sim_update_rate = 0 # Set settings self.settings = ENV_SETTINGS self.settings.update(settings) # Set variables according defaults and passed values self.variables = ENV_VARIABLES self.variables.update(variables) # Do update to calculate possibly missing variables self.update() # Set the non missing variables back to the passed values self.variables.update(variables) logger.debug(f"Created a new instance of {self}.") def __repr__(self) -> str: return f"An instance of {self.__class__.__name__} with {len(self.variables)} variables and {len(self.settings)} settings" def saveable_format(self) -> dict: saveable_format = {} saveable_format["variables"] = self.variables saveable_format["settings"] = self.settings return saveable_format def update(self) -> None: """Updates variables based on the settings and other variables""" # Add not yet being calculated variables self.variables["temperature"] = 12 self.variables["pressure"] = None # First update date-time because everything is dependent on it self.variables[ "datetime" ], self._last_update, self._real_update_rate, self._sim_update_rate = updaters.datetime( self.variables["datetime"], self._last_update, self.settings["time_factor"] ) # Next edit variables solely dependent upon date-time (and/or settings) self.variables["season"] = updaters.season(self.variables["datetime"]) self.variables["workday"] = updaters.workday( self.variables["datetime"], self.settings["country"] ) self.variables["holiday"] = updaters.holiday( self.variables["datetime"], self.settings["country"] ) self.variables["shops_open"] = self.settings["shops_model"].get_value( self.variables["datetime"] ) # Next edit variables dependent on other variables (and/or settings) self.variables.update( updaters.solar_position(**self.settings, **self.variables) ) self.variables.update( updaters.clearsky_irradiance(**self.settings, **self.variables) ) # Update variables dependent on previous (and/or settings) self.variables["solar_wind_direction"] = updaters.angle_to_winddirection( self.variables["solar_azimuth"] ) def change_settings(self, new_settings): """Updates environment settings.""" self.settings.update(new_settings) logger.info( f"Updated settings of {self} with {len(new_settings)} new or changed values." ) def print(self) -> None: """Print an overview of the current environment.""" logger.info("===========") logger.info("environment") logger.info("===========") logger.info("Last Update: {}".format(self._last_update.time())) logger.info( "Real Update Rate: {}".format(pretty_string(self._real_update_rate)) ) logger.info("Sim Update Rate: {}".format(pretty_string(self._sim_update_rate))) self.print_settings() self.print_values() def print_settings(self) -> None: """Prints all environment variables.""" logger.info("--------------------") logger.info("ENVIRONMENT SETTINGS") logger.info("--------------------") pretty_settings = pretty_dict(self.settings) for key in pretty_settings: logger.info("{}: {}".format(key, pretty_settings[key])) def print_values(self) -> None: """Prints all environment variables.""" logger.info("---------------------") logger.info("ENVIRONMENT VARIABLES") logger.info("---------------------") pretty_settings = pretty_dict(self.variables) for key in pretty_settings: logger.info("{}: {}".format(key, pretty_settings[key])) PK!%N~%thea/environment/updaters/__init__.pyfrom .season import season from .workday import workday from .workday import holiday from .astronomical import solar_position from .stacked_linear_model import StackedLinearModel from .date_time import datetime from .human_readable import angle_to_winddirection from .irradiance import clearsky_irradiance __all__ = [ "season", "workday", "holiday", "solar_position", "StackedLinearModel", "datetime", "angle_to_winddirection", "clearsky_irradiance", ] PK!])thea/environment/updaters/astronomical.pyimport pandas as pd from pvlib.location import Location def solar_position( datetime, latitude, longitude, altitude, temperature, pressure, **unused ): location = Location( latitude=latitude, longitude=longitude, tz=datetime.datetime.tzname(), altitude=altitude, ) times = pd.DatetimeIndex(start=datetime.datetime, periods=1, freq="1min") packed = location.get_solarposition( times, pressure=pressure, temperature=temperature ) apparent_zenith, zenith, apparent_elevation, elevation, azimuth, equation_of_time = packed.values[ 0 ] solar_position = { "apparent_solar_zenith": apparent_zenith, "solar_zenith": zenith, "apparent_solar_elevation": apparent_elevation, "solar_elevation": elevation, "solar_azimuth": azimuth, "equation_of_time": equation_of_time, } return solar_position PK!++&thea/environment/updaters/date_time.pyimport arrow def datetime(datetime, last_update, time_factor) -> None: """Updates date and time.""" last_update, passed_time = arrow.utcnow(), arrow.utcnow() - last_update datetime = datetime + (passed_time * time_factor) try: real_update_rate = 1 / passed_time.total_seconds() sim_update_rate = 1 / (passed_time.total_seconds() * time_factor) except ZeroDivisionError: real_update_rate = 0 sim_update_rate = 0 return datetime, last_update, real_update_rate, sim_update_rate PK!d2+thea/environment/updaters/human_readable.py"""Functions to convert environment variables to a human-comprehensible format.""" WINDDIRECTIONS = { 0: "N", 1: "NNE", 2: "NE", 3: "ENE", 4: "E", 5: "ESE", 6: "SE", 7: "SSE", 8: "S", 9: "SSW", 10: "SW", 11: "WSW", 12: "W", 13: "WNW", 14: "NW", 15: "NNW", 16: "N", } def angle_to_winddirection(angle): """Returns the wind-direction of a given angle as a `str`.""" lookup = int((angle + 11.25) / 22.5) return WINDDIRECTIONS[lookup] PK!<  'thea/environment/updaters/irradiance.pyimport pandas as pd from pvlib.location import Location def clearsky_irradiance(datetime, latitude, longitude, altitude, pressure, **unused): location = Location( latitude=latitude, longitude=longitude, tz=datetime.datetime.tzname(), altitude=altitude, ) times = pd.DatetimeIndex(start=datetime.datetime, periods=1, freq="1min") irradiance = location.get_clearsky( times, pressure=pressure ) # ineichen with climatology table by default ghi, dni, dhi = irradiance.values[0] irradiance = { "clear_sky_global_horizontal_irradiance": ghi, "clear_sky_direct_normal_irradiance": dni, "clear_sky_diffuse_horizontal_irradiance": dhi, } return irradiance PK!OKS#thea/environment/updaters/season.pySPRING = range(80, 172) SUMMER = range(172, 264) FALL = range(264, 355) # WINTER = everything else def season(date_time) -> str: """Returns the current season.""" day_of_year = int(date_time.format("DDDD")) if day_of_year in SPRING: season = "spring" elif day_of_year in SUMMER: season = "summer" elif day_of_year in FALL: season = "fall" else: season = "winter" return season PK!<=+1thea/environment/updaters/stacked_linear_model.pyimport arrow def time_to_seconds(time): return (time.hour * 60 + time.minute) * 60 + time.second class StackedLinearModel: def __init__(self, time_basis): self.data_points = [] self.time_basis = time_basis def __repr__(self): return "An instance of {} with {} datapoints".format( self.__class__.__name__, len(self.data_points) ) def __str__(self): return "{} with {} datapoints".format( self.__class__.__name__, len(self.data_points) ) def add_point(self, date_time, value): if self.time_basis == "day": self.data_points.append((time_to_seconds(date_time.time()), value)) elif self.time_basis == "year": self.data_points.append((date_time.day_of_year(), value)) else: raise NotImplementedError( 'Time basis "{}" is not implemented.'.format(self.time_basis) ) # Sort the list based on time self.data_points = sorted(self.data_points) def get_value(self, date_time): # Normalize time if isinstance(date_time, int): # Normalization is not required pass elif self.time_basis == "day": date_time = time_to_seconds(date_time.time()) else: raise NotImplementedError( 'Time basis "{}" is not implemented.'.format(self.time_basis) ) # Find ranges for i, data_point in enumerate(self.data_points): if ( date_time > self.data_points[-1][0] or date_time < self.data_points[0][0] ): start_time = self.data_points[-1][0] end_time = self.data_points[0][0] start_value = self.data_points[-1][1] end_value = self.data_points[0][1] if self.time_basis == "day": end_of_basis = 60 * 60 * 24 else: raise NotImplementedError( 'Time basis "{}" is not implemented.'.format(self.time_basis) ) time_delta = end_of_basis - start_time + end_time value_delta = end_value - start_value if date_time <= end_time: return end_value - (value_delta / time_delta) * ( end_time - date_time ) else: return (value_delta / time_delta) * ( date_time - start_time ) + start_value elif date_time <= data_point[0]: start_time = self.data_points[i - 1][0] end_time = data_point[0] start_value = self.data_points[i - 1][1] end_value = data_point[1] value_delta = end_value - start_value time_delta = end_time - start_time return (value_delta / time_delta) * ( date_time - start_time ) + start_value def plot(self): import matplotlib.pyplot as plt if self.time_basis == "day": time_range = range(0, 60 * 60 * 24, 60) else: raise NotImplementedError( 'Time basis "{}" is not implemented.'.format(self.time_basis) ) value_range = [] for time_int in time_range: value_range.append(self.get_value(time_int)) plt.plot(time_range, value_range) time_points = [p[0] for p in self.data_points] value_points = [p[1] for p in self.data_points] plt.scatter(time_points, value_points) plt.xlabel("time") plt.ylabel("value") plt.title("Stacked Linear Model") plt.show() if __name__ == "__main__": model = StackedLinearModel("day") model.add_point(arrow.get("07:00", "HH:mm"), 0) model.add_point(arrow.get("10:00", "HH:mm"), 0.9) model.add_point(arrow.get("17:00", "HH:mm"), 0.9) model.add_point(arrow.get("22:00", "HH:mm"), 0) model.plot() PK!]ܚ$thea/environment/updaters/workday.py# import workalendar # from datetime import date # from workalendar.europe import * # LOCAL_CALENDAR = { # "netherlands": Netherlands(), # "germany": Germany(), # "france": France(), # "belgium": Belgium(), # } LOCAL_CALENDAR = {"netherlands": "this was a class"} def workday(date_time, country: str) -> bool: """Returns a `bool` indicating if it is a workday.""" # holiday_calendar = LOCAL_CALENDAR[country.lower()] # return holiday_calendar.is_working_day(date_time.date()) return False def holiday(date_time, country: str): """If it is a holiday returns its name as a `str`.""" # holiday_calendar = LOCAL_CALENDAR[country.lower()] # holidays = holiday_calendar.holidays(date_time.year) # if date_time.date() in holidays: # return holidays[date_time.date()] # else: # return None return None PK!/{thea/exceptions.pyclass TheaException(Exception): """Base Thea exception""" pass class NoWorldError(TheaException): """Raise if no world has been loaded.""" pass class NameNotAvailable(TheaException): """Raise if the passed name is not available""" pass class CommNotConnectedError(TheaException): """Raise if a communicator is not connected.""" pass class MQTTBrokerError(TheaException): """Raise for errors related to the MQTT broker.""" pass class MQTTBrokerPortNotAvailible(TheaException): """Raise if the MQTT broker port is not available.""" pass class IgnoreSaved(TheaException): """Hack to raise when wanting to ignore the saved state.""" pass PK! EFQ77thea/functional_endpoints.pyfrom collections import namedtuple from queue import Queue from threading import Thread from .mqtt_hardware_types import HARDWARE_TYPES def handler(hardware_type, setter, endpoints, **properties): """Runs a setter class in a separate process.""" # setup of setter class setter = setter(hardware_type=hardware_type, endpoints=endpoints, **properties) # Running the setter class queue = Queue(5) worker = Thread(target=setter.run, args=(queue,)) worker.setDaemon(True) worker.start() return worker, queue class Setter: """A class to control which endpoints to change when.""" def __init__(self, hardware_type, endpoints, **unused): """Setup of the setter""" self.hardware_config = HARDWARE_TYPES[hardware_type] self.endpoints = endpoints def run(self, queue): while True: if queue.empty() is False: value = queue.get() for endpoint in self.endpoints: self.hardware_config[endpoint](value) # Setters must all be inherited from the setter class functionalOutput = namedtuple("functionalOutput", ["setter", "default_properties"]) # Key is the name item is an instance of functionalOutput FUNCTIONAL_OUTPUT_SETTERS = { "switch": functionalOutput(setter=Setter, default_properties={}), "blink": functionalOutput(setter=Setter, default_properties={"interval": 1}), } # type is a functional output type topicConfig = namedtuple("topicConfig", ["type_", "endpoints", "properties"]) # key is the topic, item is an instance of topic configuration CONFIGURATION_EXAMPLE = { "topic1": topicConfig("switch", [1, 2, 3], {}), "topic2": topicConfig("blink", [0, 5], {"interval": 5}), "topic3": topicConfig("switch", [4], {}), } PK!Axxthea/logging.ini# Defines logger setup [loggers] keys=root [handlers] keys=console_handler, file_handler [formatters] keys=console_formatter, file_formatter [logger_root] level=DEBUG handlers=console_handler, file_handler [handler_console_handler] class=StreamHandler level=INFO formatter=console_formatter args=(sys.stdout,) [handler_file_handler] class=FileHandler level=DEBUG formatter=file_formatter args=(log_location, 'w') [formatter_console_formatter] format=%(levelname)-9s | %(module)-25s | %(message)s [formatter_file_formatter] format=%(asctime)-9s | %(levelname)-9s | %(module)-25s | %(message)s PK!Fthea/logging_setup.py"""Functions to setup loggers.""" import os import logging import arrow from logging.config import fileConfig # from . import __version__ # Make log config relative to module LOGGING_CONFIG_LOCATION = "logging.ini" LOGS_DIRECTORY = "logs" def main_logger(): """Sets-up main logger from configuration file.""" log_name = f"thea_log_{arrow.utcnow().format('YYYYMMDD_HHmm')}" try: logs_dir = os.path.join(os.path.dirname(__file__), LOGS_DIRECTORY) # Create logs directory if not os.path.exists(logs_dir): os.makedirs(logs_dir) # Setup log directory relative to this module log_file = log_name + ".log" logging.log_location = os.path.join(logs_dir, log_file) logger = fileConfig( os.path.join(os.path.dirname(__file__), LOGGING_CONFIG_LOCATION) ) except KeyError: raise FileNotFoundError(f"Could not find {LOGGING_CONFIG_LOCATION}.") else: logger = logging.getLogger(__name__) logger.info(f"Setup root logger to save to: '{log_file}'.") # logger.info(f"Using Thea version: '{__version__}'.") return logger def vebosity(verbose=False): """Adjusts verbosity of the printing to console""" from . import logger if verbose is True: logger.parent.handlers[0].setLevel(logging.DEBUG) else: logger.parent.handlers[0].setLevel(logging.INFO) PK!\"\"thea/mqtt_hardware_module.py"""MQTT hardware module that can be started from the cli""" # TODO only save id and hw type in the client object import paho.mqtt.client as mqtt import argparse import pickle import uuid import arrow from . import logging_setup from .exceptions import IgnoreSaved from . import logger from .comm_handlers.mqtt_constants import ( MQTT_ADRESS, MQTT_PORT, MQTT_REQUEST_CONFIG_TOPIC, MQTT_SUPPLY_CONFIG_TOPIC, MQTT_STATUS_TOPIC, ) from .functional_endpoints import ( # noqa: F401 handler, FUNCTIONAL_OUTPUT_SETTERS, topicConfig, ) SAVED_STATE_FILE_NAME = "state_MQTT_module.pickle" AUTOSAVE_INTERVAL = 60 * 5 STATUS_POST_INTERVAL = 60 def on_log_callback(client, userdata, level, buf): logger.info(buf) def new_config_callback(client, userdata, message): """Handles decoding of a new configuration""" received_config = pickle.loads(message.payload) logger.info(f"Received new configuration from controller.") logger.debug(f"Received configuration: '{received_config}'.") client.received_config = received_config def message_callback(client, userdata, message): """Handle messages without specific callback.""" logger.info(f'Received message "{message.payload}" on topic "{message.topic}".') # Only keep last part of topic topic = message.topic.split("/")[-1] # If topic is a functional endpoint add value to queue if topic in client.functional_endpoints: functional_endpoint = topic value = pickle.loads(message.payload) logger.info( f'Received value "{value}" for functional endpoint "{functional_endpoint}".' ) process, queue = client.functional_endpoints[functional_endpoint] queue.put(value) logger.info( f'Added value "{value}" to queue of functional endpoint "{functional_endpoint}".' ) class MQTTHardwareModule: def __init__(self, hardware_type, ignore_config=False): # Set fixed hardware type and identifier self.hardware_type = hardware_type logger.debug(f'Hardware type is set to "{self.hardware_type}"') self.unique_identifier = f"module_{uuid.getnode()}" logger.debug(f'Unique identifier is set to "{self.unique_identifier}"') # Create MQTT client self.client = mqtt.Client(self.unique_identifier) self.client.hardware_type = self.hardware_type # Set the will to revert status to False upon disconnect self.client.will_set( topic=MQTT_STATUS_TOPIC.replace("+", self.unique_identifier), payload=pickle.dumps(False), qos=2, retain=True, ) # Connect while True: try: self.client.connect(MQTT_ADRESS, MQTT_PORT) break except ConnectionRefusedError: logger.warning( f"Failed to connect with MQTT broker at {MQTT_ADRESS}:{MQTT_PORT}." ) logger.info(f"Connected with MQTT broker at {MQTT_ADRESS}:{MQTT_PORT}.") # Set status to not ready self.publish(MQTT_STATUS_TOPIC, False) # Hardware module with configuration try: # Hack to ignore configuration if ignore_config: raise IgnoreSaved("Ignoring configuration.") # Check if a previous config is present self.config = self.load_config() # TODO check id match # Check if the identifier matches # if self.config["unique_identifier"] != self.unique_identifier: # raise Exception("Unique_identifier in configuration does not match.") except (IgnoreSaved, FileNotFoundError): # Request a new config logger.warning("No valid configuration found.") self.config = self.request_config() finally: logger.info("Completed retrieval of a configuration.") # Setup configuration self.initalize_configuration(self.config) # Setup is completed run module self.run() def load_config(self) -> dict: """Loads configuration from file""" config = pickle.load(open(SAVED_STATE_FILE_NAME, "rb")) logger.info(f'Loaded configuration from "{SAVED_STATE_FILE_NAME}"') return config def request_config(self): # Ensure there is no old config on the client object self.client.received_config = None # Add a callback to handle a new configuration self.client.message_callback_add( MQTT_SUPPLY_CONFIG_TOPIC.replace("+", self.unique_identifier), new_config_callback, ) # Subscribe to receive new configuration self.client.subscribe( MQTT_SUPPLY_CONFIG_TOPIC.replace("+", self.unique_identifier) ) # Request new configuration for hardware type self.publish(MQTT_REQUEST_CONFIG_TOPIC, self.hardware_type) logger.info("Requested new configuration.") while self.client.received_config is None: self.client.loop() logger.debug("Waiting for new configuration.") return self.client.received_config def publish(self, topic_template, data): """Publish to a topic preceded by the module id""" topic = topic_template.replace("+", self.unique_identifier) data = pickle.dumps(data) self.client.publish(topic, data) def renew_status(self): """Send alive status""" try: if (arrow.utcnow() - self.last_status_post).seconds > STATUS_POST_INTERVAL: self.publish(MQTT_STATUS_TOPIC, True) self.last_status_post = arrow.utcnow() logger.debug("Renewed status.") except AttributeError: self.publish(MQTT_STATUS_TOPIC, True) self.last_status_post = arrow.utcnow() logger.info("Renewed status.") def auto_save(self): """auto save configuration""" try: if (arrow.utcnow() - self.last_save).seconds > AUTOSAVE_INTERVAL: self.save() except AttributeError: self.save() def save(self): """Save state to file""" pickle.dump(self.config, open(SAVED_STATE_FILE_NAME, "wb")) self.last_save = arrow.utcnow() logger.debug(f'Saved configuration to "{SAVED_STATE_FILE_NAME}".') def run(self): self.client.on_log = on_log_callback self.client.subscribe(f"{self.unique_identifier}/#") self.client.on_message = message_callback logger.info("Starting loop.") while True: self.renew_status() self.auto_save() # Process messages self.client.loop() def initalize_configuration(self, configuration): """Set-sup the passed configuration""" logger.info("Starting configuration of functional endpoints.") self.client.functional_endpoints = {} for topic, topic_config in configuration.items(): # Subscribe to provided keys self.client.subscribe(f"{self.unique_identifier}/{topic}") # Setup using output handler process, queue = handler( hardware_type=self.hardware_type, setter=FUNCTIONAL_OUTPUT_SETTERS[topic_config.type_].setter, endpoints=topic_config.endpoints, **topic_config.properties, ) # Store the processes and keys in a dictionary self.client.functional_endpoints[topic] = (process, queue) logger.info("Completed configuration of functional endpoints.") def cli_mqtt_hw_module(): """Handles initial argument to start the hardware module.""" parser = argparse.ArgumentParser( description="Starts a (simulated) MQTT hardware module from the CLI." ) parser.add_argument("-t", "--type", help="Hardware type.", default="pi_mixed") parser.add_argument( "-v", "--verbose", help="Verbose printing.", action="store_true" ) parser.add_argument( "-i", "--ignore_config", help="Ignore previous configuration.", action="store_true", ) args = parser.parse_args() # Set verbosity level of logger logging_setup.vebosity(args.verbose) # Initialize and run MQTTHardwareModule(args.type, args.ignore_config).run() if __name__ == "__main__": cli_mqtt_hw_module() PK! .Ҏthea/mqtt_hardware_types.pyfrom . import logger class BinaryOut: def __init__(self, pin): self.pin = pin def set(self, value, diasable=False): logger.info(f'Binary output on pin "{self.pin}" set to "{value}".') class AnalogOut: def __init__(self, pin): self.pin = pin def set(self, value, diasable=False): logger.info(f'Analog output on pin "{self.pin}" set to "{value}".') """Hardware types where the first item is the endpoint and the second is the method to set the value on a specific hardware pin.""" HARDWARE_TYPES = { "pi_binary_out": { 0: BinaryOut(0).set, 1: BinaryOut(1).set, 2: BinaryOut(2).set, 3: BinaryOut(3).set, 4: BinaryOut(4).set, 5: BinaryOut(5).set, }, "pi_analog_out": { 0: AnalogOut(0).set, 1: AnalogOut(1).set, 2: AnalogOut(2).set, 3: AnalogOut(3).set, 4: AnalogOut(4).set, 5: AnalogOut(5).set, }, "pi_mixed": { 0: AnalogOut(0).set, 1: AnalogOut(1).set, 2: BinaryOut(2).set, 3: BinaryOut(3).set, 4: AnalogOut(4).set, 5: BinaryOut(5).set, }, } PK!K thea/mqtt_send_command.pyimport paho.mqtt.client as mqtt import pickle broker_address = "127.0.0.1" client = mqtt.Client("Command") client.connect(broker_address, 1185) client.publish("module_44831927329495/topic3", pickle.dumps(True)) PK!rthea/pretty_printing.py"""A set of functions for printing data in a esthetically pleasing way""" def pretty_string(value): """Reformats any data-type to a formatted string for printing.""" # Prevent empty spot if value is None: value = "None" # Limiting decimal numbers for floats elif isinstance(value, float): value = str(round(value, 2)) # Remove "_" and capitalize elif isinstance(value, str): value = value.replace("_", " ") # When all lower case convert to title case if value.islower(): value = value.title() # Catch everything else with simple conversion else: value = str(value) return value def pretty_dict(dictonary): """Formats dictionary with `pretty_string`.""" pretty_dictonary = {} for key in dictonary: pretty_dictonary[pretty_string(key)] = pretty_string(dictonary[key]) return pretty_dictonary PK!7)uthea/thea_world.pyimport pickle from .environment import Environment from .things import ThingsStore from .communicators import CommunicatorStore from .env_thing_linker import EnvThingsLinker from . import logger from .exceptions import NoWorldError def check_world_loaded(function): """Decorator to check if a Thea world has been loaded/created.""" def wrapper(*args, **kwargs): if args[0].world_loaded is False: raise NoWorldError( f'No Thea world loaded, cannot execute: "{function.__name__}".' ) else: return function(*args, **kwargs) return wrapper class TheaWorld: def __init__(self): self.world_loaded = False def new(self, name): """Create new world.""" # Saveables self._name = name self._environment = Environment() self._things = ThingsStore() self._communicators = CommunicatorStore() # Non-saveables self._env_thing_linker = EnvThingsLinker() # Indicate a world has been loaded self.world_loaded = True logger.info(f'Created new Thea world named: "{self._name}".') @check_world_loaded def save(self, file_name=""): """Save current world to file.""" if file_name is "": file = f"{self._name.replace(' ','_').lower()}.tw" else: file = f"{file_name}.tw" # Add all objects to dictionary to be pickled saveable_world = {} saveable_world["name"] = self.name saveable_world["environment"] = self.environment.saveable_format() saveable_world["things"] = self.things.saveable_format() saveable_world["communicators"] = self.communicators.saveable_format() pickle.dump(saveable_world, open(file, "wb")) logger.info(f'Saved Thea world named "{self._name}" to "{file}".') def load(self, file): """Load a world from file.""" loaded_world = pickle.load(open(file, "rb")) self._name = loaded_world["name"] self._environment = Environment(**loaded_world["environment"]) self._things = ThingsStore(**loaded_world["things"]) self._communicators = CommunicatorStore(**loaded_world["communicators"]) # Non-saveables self._env_thing_linker = EnvThingsLinker() # Indicate a world has been loaded self.loaded = True logger.info(f'Loaded Thea world named "{self._name}" from "{file}".') @check_world_loaded def update(self): """Update world.""" self.environment.update() self.env_thing_linker.update(self.things.items, self.environment.variables) @property def name(self): """This attribute is a property so we can check if a Thea world has been loaded.""" try: return self._name except AttributeError: raise NoWorldError(f'No Thea world loaded, cannot access "name".') @property def environment(self): """This attribute is a property so we can check if a Thea world has been loaded.""" try: return self._environment except AttributeError: raise NoWorldError(f'No Thea world loaded, cannot access "environment".') @property def things(self): """This attribute is a property so we can check if a Thea world has been loaded.""" try: return self._things except AttributeError: raise NoWorldError(f'No Thea world loaded, cannot access "things".') @property def communicators(self): """This attribute is a property so we can check if a Thea world has been loaded.""" try: return self._communicators except AttributeError: raise NoWorldError(f'No Thea world loaded, cannot access "communicators".') @property def env_thing_linker(self): """This attribute is a property so we can check if a Thea world has been loaded.""" try: return self._env_thing_linker except AttributeError: raise NoWorldError( f'No Thea world loaded, cannot access "env_thing_linker".' ) PK!thea/thing_types.pyfrom collections import namedtuple from . import thing_updaters # Values of the named tuple need to be of type: any, list of types, dict, callable ThingType = namedtuple( "ThingType", ["default_value", "permitted_values", "default_properties", "updater"] ) # A dictonary of all ThingTypes THING_TYPES = {} THING_TYPES["shop"] = ThingType( default_value=False, permitted_values=[bool], default_properties={}, updater=thing_updaters.shops, ) PK!Y??thea/thing_updaters/__init__.pyfrom .percentage_switcher import shops __all__ = ["shops"] PK!5*thea/thing_updaters/percentage_switcher.pyimport random def model_updater(things, target_value): current_on = sum([t.value for t in things]) / float(len(things)) difference = target_value - current_on if difference > 0: number_to_change = int(difference * len(things)) # Prevent nervous switching of a single light if number_to_change != 1: random.shuffle(things) to_change = [i for i in things if i.value is False][:number_to_change] for thing in to_change: thing.value = True elif difference < 0: number_to_change = int(difference * -1 * len(things)) random.shuffle(things) to_change = [i for i in things if i.value is True][:number_to_change] for thing in to_change: thing.value = False def shops(things_of_single_type, shops_open, **unused): model_updater(things=things_of_single_type, target_value=shops_open) PK!e``thea/things.pyfrom .base_itemstore import BaseItem, BaseStore from .thing_types import THING_TYPES class Thing(BaseItem): """A thing is somthing that can be controlled.""" # Define attributes that can be changed when inheriting this class type_dict = THING_TYPES def _additional_attributes(self, **kwargs) -> dict: """Handles setting atributes not defined in the BaseItem class""" # Set value to default if it has not been passed self.value = kwargs.get("value", self.type_dict[self.type_].default_value) # Remove value so it does not end up in the properties kwargs.pop("value", None) return kwargs def _additional_saveable(self) -> dict: """Handles saving atributes not defined in the BaseItem class""" saveable_format = {} saveable_format["value"] = {} return saveable_format @property def value(self): """Returns the value.""" return self._value @value.setter def value(self, value): """Checks value for correct `type` and propagates value change to hardware.""" if type(value) in THING_TYPES[self.type_].permitted_values: self._value = value # TODO: Propagate value change to hardware else: raise ValueError( f'"{type(value)}" is not a valid value type for a "{self.type}".' ) def __str__(self) -> str: return f'A {self.type_} named "{self.name}" with value "{self.value}"' class ThingsStore(BaseStore): item_to_create = Thing PK!H (+%thea-0.0.1.dist-info/entry_points.txtN+I/N.,()ʭԅmK2Rs3㹸PK!yczK K thea-0.0.1.dist-info/LICENSE.md# 📃 License Thea is available under the GNU Lesser General Public License version 3. > ## LGPL version 3 > > ### GNU LESSER GENERAL PUBLIC LICENSE > > Version 3, 29 June 2007 > > Copyright (C) 2007 Free Software Foundation, Inc. > > > Everyone is permitted to copy and distribute verbatim copies of this > license document, but changing it is not allowed. > > This version of the GNU Lesser General Public License incorporates the > terms and conditions of version 3 of the GNU General Public License, > supplemented by the additional permissions listed below. > > #### 0. Additional Definitions > > As used herein, "this License" refers to version 3 of the GNU Lesser > General Public License, and the "GNU GPL" refers to version 3 of the > GNU General Public License. > > "The Library" refers to a covered work governed by this License, other > than an Application or a Combined Work as defined below. > > An "Application" is any work that makes use of an interface provided > by the Library, but which is not otherwise based on the Library. > Defining a subclass of a class defined by the Library is deemed a mode > of using an interface provided by the Library. > > A "Combined Work" is a work produced by combining or linking an > Application with the Library. The particular version of the Library > with which the Combined Work was made is also called the "Linked > Version". > > The "Minimal Corresponding Source" for a Combined Work means the > Corresponding Source for the Combined Work, excluding any source code > for portions of the Combined Work that, considered in isolation, are > based on the Application, and not on the Linked Version. > > The "Corresponding Application Code" for a Combined Work means the > object code and/or source code for the Application, including any data > and utility programs needed for reproducing the Combined Work from the > Application, but excluding the System Libraries of the Combined Work. > > #### 1. Exception to Section 3 of the GNU GPL > > You may convey a covered work under sections 3 and 4 of this License > without being bound by section 3 of the GNU GPL. > > #### 2. Conveying Modified Versions > > If you modify a copy of the Library, and, in your modifications, a > facility refers to a function or data to be supplied by an Application > that uses the facility (other than as an argument passed when the > facility is invoked), then you may convey a copy of the modified > version: > > - a) under this License, provided that you make a good faith effort > to ensure that, in the event an Application does not supply the > function or data, the facility still operates, and performs > > - b) under the GNU GPL, with none of the additional permissions of > this License applicable to that copy. > > #### 3. Object Code Incorporating Material from Library Header Files > > The object code form of an Application may incorporate material from a > header file that is part of the Library. You may convey such object > code under terms of your choice, provided that, if the incorporated > material is not limited to numerical parameters, data structure > layouts and accessors, or small macros, inline functions and templates > (ten or fewer lines in length), you do both of the following: > > - a) Give prominent notice with each copy of the object code that > the Library is used in it and that the Library and its use are > covered by this License. > > - b) Accompany the object code with a copy of the GNU GPL and this > license document. > > #### 4. Combined Works > > You may convey a Combined Work under terms of your choice that, taken > together, effectively do not restrict modification of the portions of > the Library contained in the Combined Work and reverse engineering for > debugging such modifications, if you also do each of the following: > > - a) Give prominent notice with each copy of the Combined Work that > the Library is used in it and that the Library and its use are > covered by this License. > > - b) Accompany the Combined Work with a copy of the GNU GPL and this > license document. > > - c) For a Combined Work that displays copyright notices during > execution, include the copyright notice for the Library among > these notices, as well as a reference directing the user to the > copies of the GNU GPL and this license document. > > - d) Do one of the following: > - 0) Convey the Minimal Corresponding Source under the terms of > this License, and the Corresponding Application Code in a form > suitable for, and under terms that permit, the user to > recombine or relink the Application with a modified version of > the Linked Version to produce a modified Combined Work, in the > manner specified by section 6 of the GNU GPL for conveying > Corresponding Source. > > - 1) Use a suitable shared library mechanism for linking with > the Library. A suitable mechanism is one that (a) uses at run > time a copy of the Library already present on the user's > computer system, and (b) will operate properly with a modified > version of the Library that is interface-compatible with the > Linked Version. > > - e) Provide Installation Information, but only if you would > otherwise be required to provide such information under section 6 > of the GNU GPL, and only to the extent that such information is > necessary to install and execute a modified version of the > Combined Work produced by recombining or relinking the Application > with a modified version of the Linked Version. (If you use option > 4d0, the Installation Information must accompany the Minimal > Corresponding Source and Corresponding Application Code. If you > use option 4d1, you must provide the Installation Information in > the manner specified by section 6 of the GNU GPL for conveying > Corresponding Source.) > > #### 5. Combined Libraries > > You may place library facilities that are a work based on the Library > side by side in a single library together with other library > facilities that are not Applications and are not covered by this > License, and convey such a combined library under terms of your > choice, if you do both of the following: > > - a) Accompany the combined library with a copy of the same work > based on the Library, uncombined with any other library > facilities, conveyed under the terms of this License. > > - b) Give prominent notice with the combined library that part of it > is a work based on the Library, and explaining where to find the > accompanying uncombined form of the same work. > > #### 6. Revised Versions of the GNU Lesser General Public License > > The Free Software Foundation may publish revised and/or new versions > of the GNU Lesser General Public License from time to time. Such new > versions will be similar in spirit to the present version, but may > differ in detail to address new problems or concerns. > > Each version is given a distinguishing version number. If the Library > as you received it specifies that a certain numbered version of the > GNU Lesser General Public License "or any later version" applies to > it, you have the option of following the terms and conditions either > of that published version or of any later version published by the > Free Software Foundation. If the Library as you received it does not > specify a version number of the GNU Lesser General Public License, you > may choose any version of the GNU Lesser General Public License ever > published by the Free Software Foundation. > > If the Library as you received it specifies that a proxy can decide > whether future versions of the GNU Lesser General Public License shall > apply, that proxy's public statement of acceptance of any version is > permanent authorization for you to choose that version for the > Library. Reference: [GNU Lesser General Public License, version 3](http://www.gnu.org/licenses/lgpl-3.0.html) PK!H+dUTthea-0.0.1.dist-info/WHEEL HM K-*ϳR03rOK-J,/R(O-)T0343 /, (-JLR()*M IL*4KM̫PK!Hwthea-0.0.1.dist-info/METADATAXMoϯhKp9WhI0I1"mgzݳ N. 6 [U,?Lb$ruիX!ۈ<C&h+GeqS 1"eNDhENL|e:j%t˼t,9Y;`SfWlJg:a3q~?bs]Dq$u=|^8? Bv"[o[[j6)D!ǎ [#g=jȖzLDgKF9R \{t?ZvV@b*.2;v+-,dF;%y픩y" ʢXž:],4I!GNQ=EȅAXɤu6( eu:q%Cdžgqk['~|?R6?ALQ'!Kube~,2n/]\Nɇ==d"mv\mf| qX^,J-1CX{4ŎUY  _AH\b w˶^RJMpoxˑtEbdA[:ȅUA2})^ڂ`uuαtٲ&Lldt3ko v@?I%ۉ?}ұ`X ]A;Q'WIaiU*SX''L燍\|rfTlI!E>gFeNF*ٌ{6ٔ9HKy6%% ]֛xkժ# X&J%|GD)# Z+z TLpA F O-0H,W8vɮYХF3 'bpG3:-1ɣB9p: $?* * %, ;Jw@ubÇF%e,B';&G YG~tm&v0uF<M(4BѧU Z Ы83*8i疣4=fDxDH#ZL0 T U)J0ܽ-!`Fˏ QrFNDp&OJ'۝͟{0P Y\LNGY3^N+ofjfk;!?lSjkcXPE@4~Q-Y4Ց4P?PK!H  thea-0.0.1.dist-info/RECORD}KJ@5Ǡ"( 2a~"&+39A/N>{' :Ca!g{Z lnmG^+\n2N$#AAEC%idNmCM6,E/-j#$w 0pA VEgy8Y+k2ý#@('GgHقIҕB;Z72wq"l>`$%q #{^S_Ȅډ㫍jKyVmĖnK`@}Eg5o /fU|-ȆqH\yynKnI%2—dh[F!F1M9㥽{jcyF$o0j \ʮn J/VjILّ2ÿ>7v 7-Kp kM*ߨg=m7U'/iV F"0T.I(d MTRxT*{cM c8of4|!H/Pd@(Iq=JQǖaQIXf o:)[נJJa%k?) Ds?`"+~Oj[TM("qVJhU)^/pp=V~W~z@Z.*IS6Ω2Azr8y"o Y߃8ul'ۻ omyO8&1M➘Uo$]<#| zbfrT/蒠7md/gkj9o&K k9]Z~U wsSQ#_V2Lx4xƦ/b0-_%87^}xy8,T|+LO"ڨ1M穬n} 9P'hh۽@34Kf.Tܶw-C/il9]rΊ2Oh#M&I1XƦ"ZZ4a%݌^SO iنYLq4' z}6R̊vM=yJ!Peu% )MoF(" #U˗[&ma}~6|4t }sDU'yA=łۭVfݣ:tFJ&MWhd۴g,&:g`F)C; ؐ鰼s!BSo,FZ1= ݉8lS6ٶ9ڹzG|z'~?q6