commit
3da06fe888
@ -0,0 +1,123 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2018 Thomas Wilson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
# Shepherd
|
||||||
|
|
||||||
|
This is a project to try and standardise the management of physically remote
|
||||||
|
nodes (used mainly for solar powered sensors and actuators on farms).
|
||||||
|
|
||||||
|
Shepherd provides a modular interface to define configuration parameters and
|
||||||
|
schedule behaviour, allowing remote config changes while reducing the risk of breaking
|
||||||
|
things (requiring a long trip out to the node to recover it).
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='shepherd',
|
||||||
|
version='0.2dev',
|
||||||
|
author='Thomas Wilson',
|
||||||
|
author_email='t.wilson@distreon.com.au',
|
||||||
|
packages=['shepherd', ],
|
||||||
|
install_requires=[
|
||||||
|
'toml',
|
||||||
|
'apscheduler',
|
||||||
|
'paramiko'
|
||||||
|
],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': ['shepherd=shepherd.core:main'],
|
||||||
|
},
|
||||||
|
license='MIT license',
|
||||||
|
description='Herd your mob of physically remote nodes',
|
||||||
|
long_description=open('README.md').read(),
|
||||||
|
)
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
# Should be copied to /lib/systemd/system/ then enabled with
|
||||||
|
# sudo systemctl enable shepherd.service
|
||||||
|
[Unit]
|
||||||
|
Description=Monitoring and control service for remote nodes
|
||||||
|
After=multi-user.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Environment=PYTHONUNBUFFERED=true
|
||||||
|
ExecStart=/usr/local/bin/shepherd /home/pi/shepherd.toml
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@ -0,0 +1,223 @@
|
|||||||
|
import re
|
||||||
|
import toml
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidConfigError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# On start,
|
||||||
|
# get conf_defs via imports in core
|
||||||
|
# Load a conf file
|
||||||
|
# Validate the Shepherd table in conf file, then load its values to get module list
|
||||||
|
# Validate other loaded tables/modules, then load their values
|
||||||
|
|
||||||
|
# How do modules wind up with their instance of ConfigValues? Return val instance
|
||||||
|
# from validation function - as it needs to build the vals instance while validating anyway
|
||||||
|
|
||||||
|
# Validate a conf file given a module or config_def list
|
||||||
|
|
||||||
|
|
||||||
|
# idea is to create these similar to how arg parser works
|
||||||
|
|
||||||
|
# how to store individual items in def? Dict of...
|
||||||
|
# Tables need a dict, lists/arrays need a list of dicts, but what's in the dict?
|
||||||
|
# if it's another instacnce of ConfigDef, then each Config Def needs to handle
|
||||||
|
# one item, but cater for all the different types of items - and many of those
|
||||||
|
# should be able to add a new item.
|
||||||
|
|
||||||
|
# could have a separate class for the root, but lower tables really need to
|
||||||
|
# perform exactly the same...
|
||||||
|
|
||||||
|
# config def required interface:
|
||||||
|
# Validate values.
|
||||||
|
|
||||||
|
class _ConfigDefinition():
|
||||||
|
def __init__(self, default=None, optional=False):
|
||||||
|
self.default = default
|
||||||
|
self.optional = optional
|
||||||
|
|
||||||
|
def validate(self, value): # pylint: disable=W0613
|
||||||
|
raise TypeError("_ConfigDefinition.validate() is an abstract method")
|
||||||
|
|
||||||
|
|
||||||
|
class BoolDef(_ConfigDefinition):
|
||||||
|
def __init__(self, default=None, optional=False): # pylint: disable=W0235
|
||||||
|
super().__init__(default, optional)
|
||||||
|
|
||||||
|
def validate(self, value):
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise InvalidConfigError("Config value must be a boolean")
|
||||||
|
|
||||||
|
|
||||||
|
class IntDef(_ConfigDefinition):
|
||||||
|
def __init__(self, default=None, minval=None, maxval=None,
|
||||||
|
optional=False):
|
||||||
|
super().__init__(default, optional)
|
||||||
|
self.minval = minval
|
||||||
|
self.maxval = maxval
|
||||||
|
|
||||||
|
def validate(self, value):
|
||||||
|
if not isinstance(value, int):
|
||||||
|
raise InvalidConfigError("Config value must be an integer")
|
||||||
|
if self.minval is not None and value < self.minval:
|
||||||
|
raise InvalidConfigError("Config value must be >= " +
|
||||||
|
str(self.minval))
|
||||||
|
if self.maxval is not None and value > self.maxval:
|
||||||
|
raise InvalidConfigError("Config value must be <= " +
|
||||||
|
str(self.maxval))
|
||||||
|
|
||||||
|
|
||||||
|
class StringDef(_ConfigDefinition):
|
||||||
|
def __init__(self, default=None, minlength=None, maxlength=None,
|
||||||
|
optional=False):
|
||||||
|
super().__init__(default, optional)
|
||||||
|
self.minlength = minlength
|
||||||
|
self.maxlength = maxlength
|
||||||
|
|
||||||
|
def validate(self, value):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise InvalidConfigError("Config value must be a string")
|
||||||
|
if self.minlength is not None and len(value) < self.minlength:
|
||||||
|
raise InvalidConfigError("Config string length must be >= " +
|
||||||
|
str(self.minlength))
|
||||||
|
if self.maxlength is not None and len(value) > self.maxlength:
|
||||||
|
raise InvalidConfigError("Config string length must be <= " +
|
||||||
|
str(self.maxlength))
|
||||||
|
|
||||||
|
|
||||||
|
class TableDef(_ConfigDefinition):
|
||||||
|
def __init__(self, default=None, optional=False):
|
||||||
|
super().__init__(default, optional)
|
||||||
|
self.def_table = {}
|
||||||
|
|
||||||
|
def add_def(self, name, newdef):
|
||||||
|
if not isinstance(newdef, _ConfigDefinition):
|
||||||
|
raise TypeError("Config definiton must be an instance of a "
|
||||||
|
"ConfigDefinition subclass")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise TypeError("Config definition name must be a string")
|
||||||
|
self.def_table[name] = newdef
|
||||||
|
return newdef
|
||||||
|
|
||||||
|
def validate(self, value_table): # pylint: disable=W0221
|
||||||
|
def_set = set(self.def_table.keys())
|
||||||
|
value_set = set(value_table.keys())
|
||||||
|
|
||||||
|
for missing_key in def_set - value_set:
|
||||||
|
if not self.def_table[missing_key].optional:
|
||||||
|
raise InvalidConfigError("Table must contain key: " +
|
||||||
|
missing_key)
|
||||||
|
else:
|
||||||
|
value_table[missing_key] = self.def_table[missing_key].default
|
||||||
|
|
||||||
|
for extra_key in value_set - def_set:
|
||||||
|
raise InvalidConfigError("Table contains unknown key: " +
|
||||||
|
extra_key)
|
||||||
|
|
||||||
|
for key, value in value_table.items():
|
||||||
|
try:
|
||||||
|
self.def_table[key].validate(value)
|
||||||
|
except InvalidConfigError as e:
|
||||||
|
e.args = ("Key: " + key,) + e.args
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class _ArrayDefMixin():
|
||||||
|
def validate(self, value_array):
|
||||||
|
if not isinstance(value_array, list):
|
||||||
|
raise InvalidConfigError("Config item must be an array")
|
||||||
|
for index, value in enumerate(value_array):
|
||||||
|
try:
|
||||||
|
super().validate(value)
|
||||||
|
except InvalidConfigError as e:
|
||||||
|
e.args = ("Array index: " + str(index),) + e.args
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class BoolArrayDef(_ArrayDefMixin, BoolDef):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntArrayDef(_ArrayDefMixin, IntDef):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StringArrayDef(_ArrayDefMixin, StringDef):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TableArrayDef(_ArrayDefMixin, TableDef):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfDefinition(TableDef):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigManager():
|
||||||
|
def __init__(self):
|
||||||
|
self.root_config = {}
|
||||||
|
|
||||||
|
def load(self, source):
|
||||||
|
if isinstance(source, dict): # load from dict
|
||||||
|
self.root_config = source
|
||||||
|
elif isinstance(source, str): # load from pathname
|
||||||
|
with open(source, 'r') as conf_file:
|
||||||
|
self.root_config = toml.load(conf_file)
|
||||||
|
else: # load from file
|
||||||
|
self.root_config = toml.load(source)
|
||||||
|
|
||||||
|
def get_config(self, table_name, conf_def):
|
||||||
|
if not isinstance(conf_def, ConfDefinition):
|
||||||
|
raise TypeError("Supplied config definition must be an instance "
|
||||||
|
"of ConfDefinition")
|
||||||
|
if table_name not in self.root_config:
|
||||||
|
raise InvalidConfigError("Config must contain table: " + table_name)
|
||||||
|
try:
|
||||||
|
conf_def.validate(self.root_config[table_name])
|
||||||
|
except InvalidConfigError as e:
|
||||||
|
e.args = ("Module: " + table_name,) + e.args
|
||||||
|
raise
|
||||||
|
return self.root_config[table_name]
|
||||||
|
|
||||||
|
def get_configs(self, conf_defs):
|
||||||
|
config_values = {}
|
||||||
|
for name, conf_def in conf_defs.items():
|
||||||
|
config_values[name] = self.get_config(name, conf_def)
|
||||||
|
return config_values
|
||||||
|
|
||||||
|
def get_module_configs(self, modules):
|
||||||
|
config_values = {}
|
||||||
|
for name, module in modules.items():
|
||||||
|
config_values[name] = self.get_config(name, module.conf_def)
|
||||||
|
return config_values
|
||||||
|
|
||||||
|
def dump_toml(self):
|
||||||
|
return toml.dumps(self.root_config)
|
||||||
|
|
||||||
|
def dump_to_file(self, filepath, message=None):
|
||||||
|
with open(filepath, 'w+') as f:
|
||||||
|
content = self.dump_toml()
|
||||||
|
if message is not None:
|
||||||
|
content = content.rstrip() + gen_comment(message)
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_toml_message(string):
|
||||||
|
print("stripping...")
|
||||||
|
return re.sub("(?m)^#\\ shepherd_message:[^\\n]*$\\n?(?:^#[^\\n]+$\\n?)*",
|
||||||
|
'', string)
|
||||||
|
|
||||||
|
|
||||||
|
def update_toml_message(filepath, message):
|
||||||
|
with open(filepath, 'r+') as f:
|
||||||
|
content = f.read()
|
||||||
|
content = strip_toml_message(content).rstrip()
|
||||||
|
content += gen_comment(message)
|
||||||
|
f.seek(0)
|
||||||
|
f.write(content)
|
||||||
|
f.truncate()
|
||||||
|
|
||||||
|
|
||||||
|
def gen_comment(string):
|
||||||
|
return '\n# shepherd_message: ' + '\n# '.join(string.replace('#', '').splitlines()) + '\n'
|
||||||
@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# depends on:
|
||||||
|
# python 3.4 (included in Raspbian Jessie)
|
||||||
|
# APScheduler
|
||||||
|
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import toml
|
||||||
|
|
||||||
|
|
||||||
|
import shepherd.config
|
||||||
|
import shepherd.module
|
||||||
|
|
||||||
|
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
# Future implementations of checking config differences should be done on
|
||||||
|
# the hash of the nested conf dict, so comments shouldn't affect this.
|
||||||
|
|
||||||
|
# save old config to somewhere in the shepherd root dir - probably need to
|
||||||
|
# implement a TOML writer in the config module.
|
||||||
|
|
||||||
|
# later on, there's going to be an issue with a new config being applied
|
||||||
|
# remotely, then the system restarting, and an old edit in /boot being
|
||||||
|
# applied over the top...
|
||||||
|
# Fix this by saving the working config to /boot when new config applied
|
||||||
|
# remotely.
|
||||||
|
|
||||||
|
def load_config(config_path):
|
||||||
|
confman = shepherd.config.ConfigManager()
|
||||||
|
confman.load(os.path.expanduser(config_path))
|
||||||
|
core_conf = confman.get_config("shepherd", core_confdef())
|
||||||
|
|
||||||
|
# Check for an edit_conf file, and try to load it
|
||||||
|
try:
|
||||||
|
edit_confman = shepherd.config.ConfigManager()
|
||||||
|
edit_confman.load(os.path.expanduser(core_conf["conf_edit_path"]))
|
||||||
|
core_edit_conf = edit_confman.get_config("shepherd", core_confdef())
|
||||||
|
|
||||||
|
mod_classes = shepherd.module.find_modules(core_edit_conf["modules"])
|
||||||
|
mod_configs = edit_confman.get_module_configs(mod_classes)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
conf_edit_message = None
|
||||||
|
except shepherd.config.InvalidConfigError as e:
|
||||||
|
conf_edit_message = "Invalid config.\n " + str(e.args)
|
||||||
|
except toml.TomlDecodeError as e:
|
||||||
|
conf_edit_message = "TOML syntax error.\n" + str(e)
|
||||||
|
except Exception:
|
||||||
|
conf_edit_message = "Error processing new config"
|
||||||
|
else:
|
||||||
|
conf_edit_message = ("Successfully applied this config at:" +
|
||||||
|
str(datetime.now()))
|
||||||
|
confman = edit_confman
|
||||||
|
core_conf = core_edit_conf
|
||||||
|
|
||||||
|
if conf_edit_message is not None:
|
||||||
|
shepherd.config.update_toml_message(
|
||||||
|
os.path.expanduser(core_conf["conf_edit_path"]), conf_edit_message)
|
||||||
|
|
||||||
|
# if editconf failed, load current config
|
||||||
|
if confman is not edit_confman:
|
||||||
|
mod_classes = shepherd.module.find_modules(core_conf["modules"])
|
||||||
|
mod_configs = confman.get_module_configs(mod_classes)
|
||||||
|
|
||||||
|
# If no editconf file was found, write out the current config as a template
|
||||||
|
if conf_edit_message is None:
|
||||||
|
confman.dump_to_file(os.path.expanduser(core_conf["conf_edit_path"]),
|
||||||
|
"Config generated at:" + str(datetime.now()))
|
||||||
|
|
||||||
|
return (core_conf, mod_classes, mod_configs)
|
||||||
|
|
||||||
|
|
||||||
|
def core_confdef():
|
||||||
|
confdef = shepherd.config.ConfDefinition()
|
||||||
|
confdef.add_def("id", shepherd.config.StringDef())
|
||||||
|
confdef.add_def("modules", shepherd.config.StringArrayDef())
|
||||||
|
confdef.add_def("root_dir", shepherd.config.StringDef())
|
||||||
|
confdef.add_def("conf_edit_path", shepherd.config.StringDef())
|
||||||
|
return confdef
|
||||||
|
|
||||||
|
|
||||||
|
class ShepherdInterface(shepherd.module.Interface):
|
||||||
|
def __init__(self, scheduler, config):
|
||||||
|
super().__init__(None)
|
||||||
|
self.id = config["id"]
|
||||||
|
self.root_dir = config["root_dir"]
|
||||||
|
self.scheduler = scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argparser = argparse.ArgumentParser(description="Keep track of a mob "
|
||||||
|
"of roaming Pis")
|
||||||
|
argparser.add_argument("configfile", nargs='?', metavar="configfile",
|
||||||
|
help="Path to configfile", default="shepherd.toml")
|
||||||
|
|
||||||
|
args = argparser.parse_args()
|
||||||
|
|
||||||
|
confman = shepherd.config.ConfigManager()
|
||||||
|
confman.load(os.path.expanduser(args.configfile))
|
||||||
|
core_conf = confman.get_config("shepherd", core_confdef())
|
||||||
|
breakpoint()
|
||||||
|
|
||||||
|
(core_conf, mod_classes, mod_configs) = load_config(args.configfile)
|
||||||
|
|
||||||
|
scheduler = BlockingScheduler()
|
||||||
|
core_interface = ShepherdInterface(scheduler, core_conf)
|
||||||
|
|
||||||
|
# get validated config values for modules, then instantiate them
|
||||||
|
modules = {}
|
||||||
|
mod_interfaces = {}
|
||||||
|
for name, mod_class in mod_classes.items():
|
||||||
|
modules[name] = mod_class(mod_configs[name], core_interface)
|
||||||
|
mod_interfaces[name] = modules[name].interface
|
||||||
|
|
||||||
|
# run post init after all modules are loaded to allow them to hook in to
|
||||||
|
# each other
|
||||||
|
mod_interfaces = SimpleNamespace(**mod_interfaces)
|
||||||
|
for module in modules.values():
|
||||||
|
module.init_other_interfaces(mod_interfaces)
|
||||||
|
|
||||||
|
print(str(datetime.now()))
|
||||||
|
print('Press Ctrl+{0} to exit'.format('Break' if os.name == 'nt' else 'C'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheduler.start()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from contextlib import suppress
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
class Hook():
|
||||||
|
def __init__(self):
|
||||||
|
self.attached_functions = []
|
||||||
|
|
||||||
|
def attach(self, new_func):
|
||||||
|
if not callable(new_func):
|
||||||
|
raise TypeError("Argument to Hook.attach must be callable")
|
||||||
|
self.attached_functions.append(new_func)
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
for func in self.attached_functions:
|
||||||
|
func(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Module():
|
||||||
|
def __init__(self, config, core_interface):
|
||||||
|
self.config = config
|
||||||
|
self.shepherd = core_interface
|
||||||
|
#self.shepherd.scheduler
|
||||||
|
|
||||||
|
self.interface = Interface(self)
|
||||||
|
self.modules = {}
|
||||||
|
# dummy interface in case module doesn't want one
|
||||||
|
|
||||||
|
def init_other_interfaces(self, interfaces):
|
||||||
|
if not isinstance(self.interface, Interface):
|
||||||
|
raise TypeError("shepherd.module.Module interface attribute must "
|
||||||
|
"be subclass of type shepherd.module.Interface")
|
||||||
|
self.modules = interfaces
|
||||||
|
|
||||||
|
|
||||||
|
# Look at providing a run() function or similar, which is a thread started
|
||||||
|
# post_modules_setup
|
||||||
|
class SimpleModule(Module):
|
||||||
|
def __init__(self, config, core_interface):
|
||||||
|
super().__init__(config, core_interface)
|
||||||
|
self.setup()
|
||||||
|
|
||||||
|
def init_other_interfaces(self, interfaces):
|
||||||
|
super().init_other_interfaces(interfaces)
|
||||||
|
self.setup_other_modules()
|
||||||
|
|
||||||
|
# Users override this, self.shepherd and self.config are available now.
|
||||||
|
# User still needs to define self.interface if it is used.
|
||||||
|
def setup(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def setup_other_modules(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
An interface to a Shepherd module, accessible by other modules.
|
||||||
|
All public methods in a module interface need to be threadsafe, as they will
|
||||||
|
be called by other modules (which generally run in a seperate thread)
|
||||||
|
"""
|
||||||
|
class Interface():
|
||||||
|
def __init__(self, module):
|
||||||
|
self._module = module
|
||||||
|
|
||||||
|
|
||||||
|
def find_modules(module_names):
|
||||||
|
module_classes = {}
|
||||||
|
for module_name in module_names:
|
||||||
|
mod = importlib.import_module("shepherd.modules." + module_name)
|
||||||
|
attrs = [getattr(mod, name) for name in dir(mod)]
|
||||||
|
|
||||||
|
for attr in attrs:
|
||||||
|
with suppress(TypeError):
|
||||||
|
if issubclass(attr, Module):
|
||||||
|
module_classes[module_name] = attr
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise Exception("Imported shepherd modules must contain a "
|
||||||
|
"subclass of shepherd.module.Module, such as"
|
||||||
|
"shepherd.module.SimpleModule")
|
||||||
|
|
||||||
|
return module_classes
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import shepherd.config
|
||||||
|
import shepherd.module
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from gpiozero import OutputDevice, Device
|
||||||
|
from gpiozero.pins.pigpio import PiGPIOFactory
|
||||||
|
|
||||||
|
from shepherd.modules.betterservo import BetterServo
|
||||||
|
|
||||||
|
Device.pin_factory = PiGPIOFactory()
|
||||||
|
|
||||||
|
|
||||||
|
APHIDTRAP_LED_PIN = 5 #Out2
|
||||||
|
|
||||||
|
|
||||||
|
class AphidtrapConfDef(shepherd.config.ConfDefinition):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
|
||||||
|
class AphidtrapModule(shepherd.module.SimpleModule):
|
||||||
|
conf_def = AphidtrapConfDef()
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
|
||||||
|
print("Aphidtrap config:")
|
||||||
|
print(self.config)
|
||||||
|
|
||||||
|
self.led_power = OutputDevice(APHIDTRAP_LED_PIN,
|
||||||
|
active_high=True,
|
||||||
|
initial_value=False)
|
||||||
|
|
||||||
|
def setup_other_modules(self):
|
||||||
|
self.modules.picam.hook_pre_cam.attach(self.led_on)
|
||||||
|
self.modules.picam.hook_post_cam.attach(self.led_off)
|
||||||
|
|
||||||
|
def led_on(self):
|
||||||
|
self.led_power.on()
|
||||||
|
|
||||||
|
def led_off(self):
|
||||||
|
self.led_power.off()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
argparser = argparse.ArgumentParser(
|
||||||
|
description='Module for aphidtrap control functions. Run for testing')
|
||||||
|
argparser.add_argument("configfile", nargs='?', metavar="configfile",
|
||||||
|
help="Path to configfile", default="conf.toml")
|
||||||
|
|
||||||
|
|
||||||
|
args = argparser.parse_args()
|
||||||
|
confman = shepherd.config.ConfigManager()
|
||||||
|
|
||||||
|
srcdict = {"aphidtrap": {}}
|
||||||
|
|
||||||
|
if os.path.isfile(args.configfile):
|
||||||
|
confman.load(args.configfile)
|
||||||
|
else:
|
||||||
|
confman.load(srcdict)
|
||||||
|
|
||||||
|
aphidtrap_mod = AphidtrapModule(confman.get_config("aphid", AphidtrapConfDef()),
|
||||||
|
shepherd.module.Interface(None))
|
||||||
|
|
||||||
|
aphidtrap_mod.led_on()
|
||||||
|
time.sleep(2)
|
||||||
|
aphidtrap_mod.led_off()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
from gpiozero import PWMOutputDevice, SourceMixin, CompositeDevice
|
||||||
|
|
||||||
|
|
||||||
|
class BetterServo(SourceMixin, CompositeDevice):
|
||||||
|
"""
|
||||||
|
Copy of GPIOZero servo, but with control over pulse width and active_high
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self, pin=None, initial_value=0.0,
|
||||||
|
min_pulse_width=1/1000, max_pulse_width=2/1000,
|
||||||
|
frame_width=20/1000, pin_factory=None, active_high=True):
|
||||||
|
if min_pulse_width >= max_pulse_width:
|
||||||
|
raise ValueError('min_pulse_width must be less than max_pulse_width')
|
||||||
|
if max_pulse_width >= frame_width:
|
||||||
|
raise ValueError('max_pulse_width must be less than frame_width')
|
||||||
|
self._frame_width = frame_width
|
||||||
|
self._min_dc = min_pulse_width / frame_width
|
||||||
|
self._dc_range = (max_pulse_width - min_pulse_width) / frame_width
|
||||||
|
self._min_value = -1
|
||||||
|
self._value_range = 2
|
||||||
|
super(BetterServo, self).__init__(
|
||||||
|
pwm_device=PWMOutputDevice(
|
||||||
|
pin, frequency=int(1 / frame_width), pin_factory=pin_factory,
|
||||||
|
active_high=False
|
||||||
|
),
|
||||||
|
pin_factory=pin_factory
|
||||||
|
)
|
||||||
|
self.pwm_device.active_high=active_high
|
||||||
|
try:
|
||||||
|
self.value = initial_value
|
||||||
|
except:
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frame_width(self):
|
||||||
|
"""
|
||||||
|
The time between control pulses, measured in seconds.
|
||||||
|
"""
|
||||||
|
return self._frame_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_pulse_width(self):
|
||||||
|
"""
|
||||||
|
The control pulse width corresponding to the servo's minimum position,
|
||||||
|
measured in seconds.
|
||||||
|
"""
|
||||||
|
return self._min_dc * self.frame_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_pulse_width(self):
|
||||||
|
"""
|
||||||
|
The control pulse width corresponding to the servo's maximum position,
|
||||||
|
measured in seconds.
|
||||||
|
"""
|
||||||
|
return (self._dc_range * self.frame_width) + self.min_pulse_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pulse_width(self):
|
||||||
|
"""
|
||||||
|
Returns the current pulse width controlling the servo.
|
||||||
|
"""
|
||||||
|
if self.pwm_device.frequency is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return self.pwm_device.state * self.frame_width
|
||||||
|
|
||||||
|
@pulse_width.setter
|
||||||
|
def pulse_width(self, value):
|
||||||
|
if value is None:
|
||||||
|
self.pwm_device.frequency = None
|
||||||
|
elif self.min_pulse_width <= value <= self.max_pulse_width:
|
||||||
|
self.pwm_device.frequency = int(1 / self.frame_width)
|
||||||
|
self.pwm_device.value = (value / self.frame_width)
|
||||||
|
else:
|
||||||
|
raise OutputDeviceBadValue("Servo pulse_width must be between min and max supplied during construction, or None")
|
||||||
|
|
||||||
|
def min(self):
|
||||||
|
"""
|
||||||
|
Set the servo to its minimum position.
|
||||||
|
"""
|
||||||
|
self.value = -1
|
||||||
|
|
||||||
|
def mid(self):
|
||||||
|
"""
|
||||||
|
Set the servo to its mid-point position.
|
||||||
|
"""
|
||||||
|
self.value = 0
|
||||||
|
|
||||||
|
def max(self):
|
||||||
|
"""
|
||||||
|
Set the servo to its maximum position.
|
||||||
|
"""
|
||||||
|
self.value = 1
|
||||||
|
|
||||||
|
def detach(self):
|
||||||
|
"""
|
||||||
|
Temporarily disable control of the servo. This is equivalent to
|
||||||
|
setting :attr:`value` to ``None``.
|
||||||
|
"""
|
||||||
|
self.value = None
|
||||||
|
|
||||||
|
def _get_value(self):
|
||||||
|
if self.pwm_device.frequency is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
((self.pwm_device.state - self._min_dc) / self._dc_range) *
|
||||||
|
self._value_range + self._min_value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self):
|
||||||
|
"""
|
||||||
|
Represents the position of the servo as a value between -1 (the minimum
|
||||||
|
position) and +1 (the maximum position). This can also be the special
|
||||||
|
value ``None`` indicating that the servo is currently "uncontrolled",
|
||||||
|
i.e. that no control signal is being sent. Typically this means the
|
||||||
|
servo's position remains unchanged, but that it can be moved by hand.
|
||||||
|
"""
|
||||||
|
result = self._get_value()
|
||||||
|
if result is None:
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# NOTE: This round() only exists to ensure we don't confuse people
|
||||||
|
# by returning 2.220446049250313e-16 as the default initial value
|
||||||
|
# instead of 0. The reason _get_value and _set_value are split
|
||||||
|
# out is for descendents that require the un-rounded values for
|
||||||
|
# accuracy
|
||||||
|
return round(result, 14)
|
||||||
|
|
||||||
|
@value.setter
|
||||||
|
def value(self, value):
|
||||||
|
if value is None:
|
||||||
|
self.pwm_device.frequency = None
|
||||||
|
elif -1 <= value <= 1:
|
||||||
|
self.pwm_device.frequency = int(1 / self.frame_width)
|
||||||
|
self.pwm_device.value = (
|
||||||
|
self._min_dc + self._dc_range *
|
||||||
|
((value - self._min_value) / self._value_range)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise OutputDeviceBadValue(
|
||||||
|
"Servo value must be between -1 and 1, or None")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return self.value is not None
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import shepherd.config
|
||||||
|
import shepherd.module
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from gpiozero import OutputDevice, Device
|
||||||
|
from gpiozero.pins.pigpio import PiGPIOFactory
|
||||||
|
|
||||||
|
from shepherd.modules.betterservo import BetterServo
|
||||||
|
|
||||||
|
Device.pin_factory = PiGPIOFactory()
|
||||||
|
|
||||||
|
|
||||||
|
MOTHTRAP_LED_PIN = 6
|
||||||
|
MOTHTRAP_SERVO_PIN = 10
|
||||||
|
MOTHTRAP_SERVO_POWER_PIN = 9
|
||||||
|
|
||||||
|
|
||||||
|
class MothtrapConfDef(shepherd.config.ConfDefinition):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.add_def('servo_open_pulse', shepherd.config.IntDef(default=1200))
|
||||||
|
self.add_def('servo_closed_pulse', shepherd.config.IntDef(default=1800))
|
||||||
|
self.add_def('servo_open_time', shepherd.config.IntDef(default=5))
|
||||||
|
|
||||||
|
|
||||||
|
class MothtrapModule(shepherd.module.SimpleModule):
|
||||||
|
conf_def = MothtrapConfDef()
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
|
||||||
|
print("Mothtrap config:")
|
||||||
|
print(self.config)
|
||||||
|
|
||||||
|
servo_max = self.config["servo_open_pulse"] / 1000000
|
||||||
|
servo_min = self.config["servo_closed_pulse"] / 1000000
|
||||||
|
|
||||||
|
if servo_min > servo_max:
|
||||||
|
servo_min, servo_max = servo_max, servo_min
|
||||||
|
|
||||||
|
self.door_servo = BetterServo(MOTHTRAP_SERVO_PIN, initial_value=None,
|
||||||
|
active_high=False,
|
||||||
|
min_pulse_width=servo_min,
|
||||||
|
max_pulse_width=servo_max)
|
||||||
|
|
||||||
|
self.door_servo_power = OutputDevice(MOTHTRAP_SERVO_POWER_PIN,
|
||||||
|
active_high=True,
|
||||||
|
initial_value=False)
|
||||||
|
|
||||||
|
self.led_power = OutputDevice(MOTHTRAP_LED_PIN,
|
||||||
|
active_high=True,
|
||||||
|
initial_value=False)
|
||||||
|
|
||||||
|
def setup_other_modules(self):
|
||||||
|
self.modules.picam.hook_pre_cam.attach(self.led_on)
|
||||||
|
self.modules.picam.hook_post_cam.attach(self.led_off)
|
||||||
|
self.modules.picam.hook_post_cam.attach(self.run_servo)
|
||||||
|
|
||||||
|
def led_on(self):
|
||||||
|
self.led_power.on()
|
||||||
|
|
||||||
|
def led_off(self):
|
||||||
|
self.led_power.off()
|
||||||
|
|
||||||
|
def run_servo(self):
|
||||||
|
self.door_servo_power.on()
|
||||||
|
time.sleep(0.5)
|
||||||
|
self.door_servo.pulse_width = self.config["servo_open_pulse"] / 1000000
|
||||||
|
time.sleep(self.config["servo_open_time"])
|
||||||
|
self.door_servo.pulse_width = self.config["servo_closed_pulse"] / 1000000
|
||||||
|
time.sleep(self.config["servo_open_time"])
|
||||||
|
self.door_servo.detach()
|
||||||
|
self.door_servo_power.off()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
argparser = argparse.ArgumentParser(
|
||||||
|
description='Module for mothtrap control functions. Run for testing')
|
||||||
|
argparser.add_argument("configfile", nargs='?', metavar="configfile",
|
||||||
|
help="Path to configfile", default="conf.toml")
|
||||||
|
argparser.add_argument("test_function", nargs='?', choices=['servo'],
|
||||||
|
help="test function to perform", default="servo")
|
||||||
|
argparser.add_argument("-o", help="servo open position, in us", type=int, default=1200, dest="servo_open_pulse")
|
||||||
|
argparser.add_argument("-c", help="servo closed position, in us", type=int, default=1800, dest="servo_closed_pulse")
|
||||||
|
argparser.add_argument("-w", help="wait time, in seconds", type=int, default=5, dest="servo_open_time")
|
||||||
|
|
||||||
|
args = argparser.parse_args()
|
||||||
|
confman = shepherd.config.ConfigManager()
|
||||||
|
|
||||||
|
srcdict = {"mothtrap": {"servo_open_pulse": args.servo_open_pulse,
|
||||||
|
"servo_closed_pulse":args.servo_closed_pulse,
|
||||||
|
"servo_open_time":args.servo_open_time}}
|
||||||
|
|
||||||
|
if os.path.isfile(args.configfile):
|
||||||
|
confman.load(args.configfile)
|
||||||
|
else:
|
||||||
|
confman.load(srcdict)
|
||||||
|
|
||||||
|
mothtrap_mod = MothtrapModule(confman.get_config("mothtrap", MothtrapConfDef()),
|
||||||
|
shepherd.module.Interface(None))
|
||||||
|
|
||||||
|
mothtrap_mod.led_on()
|
||||||
|
mothtrap_mod.run_servo()
|
||||||
|
mothtrap_mod.led_off()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
import shepherd.config
|
||||||
|
import shepherd.module
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
import time
|
||||||
|
from picamera import PiCamera
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
|
||||||
|
overlayfont = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
|
||||||
|
|
||||||
|
|
||||||
|
class PiCamConfDef(shepherd.config.ConfDefinition):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.add_def('upload_images', shepherd.config.BoolDef(default=False, optional=True))
|
||||||
|
self.add_def('upload_bucket', shepherd.config.StringDef(default="", optional=True))
|
||||||
|
self.add_def('save_directory', shepherd.config.StringDef(default="", optional=False))
|
||||||
|
self.add_def('append_text', shepherd.config.StringDef(default="", optional=True))
|
||||||
|
self.add_def('append_id', shepherd.config.BoolDef(default=True, optional=True))
|
||||||
|
|
||||||
|
array = self.add_def('trigger', shepherd.config.TableArrayDef())
|
||||||
|
array.add_def('hour', shepherd.config.StringDef())
|
||||||
|
array.add_def('minute', shepherd.config.StringDef())
|
||||||
|
array.add_def('second', shepherd.config.StringDef(default="0", optional=True))
|
||||||
|
|
||||||
|
# on server side, we want to be able to list commands that a module responds to
|
||||||
|
# without actually instantiating the module class. Add command templates into
|
||||||
|
# the conf_def, than attach to them in the interface? Was worried about having
|
||||||
|
# "two sources of truth", but you already need to match the conf_def to the
|
||||||
|
# name where you access the value in the module. Could have add_command, which
|
||||||
|
# you then add standard conf_def subclasses to, to reuse validation and server
|
||||||
|
# form generation logic...
|
||||||
|
|
||||||
|
|
||||||
|
class PiCamInterface(shepherd.module.Interface):
|
||||||
|
def __init__(self, module):
|
||||||
|
super().__init__(module)
|
||||||
|
|
||||||
|
self.hook_pre_cam = shepherd.module.Hook()
|
||||||
|
self.hook_post_cam = shepherd.module.Hook()
|
||||||
|
|
||||||
|
# self.add_command("trigger", self.module.camera_job)
|
||||||
|
|
||||||
|
# other module can then call, in init_interfaces, if self.modules.picam is not None:
|
||||||
|
# self.modules.picam.hooks.attach("pre_cam",self.myfunc)
|
||||||
|
# self.modules.picam.pre_cam.attach(self.my_func)
|
||||||
|
|
||||||
|
# self.modules.picam.trigger()
|
||||||
|
|
||||||
|
|
||||||
|
class PiCamModule(shepherd.module.SimpleModule):
|
||||||
|
conf_def = PiCamConfDef()
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
self.interface = PiCamInterface(self)
|
||||||
|
# do some camera init stuff
|
||||||
|
|
||||||
|
print("Camera config:")
|
||||||
|
print(self.config)
|
||||||
|
|
||||||
|
if self.config["save_directory"] is "":
|
||||||
|
self.save_directory = os.path.join(self.shepherd.root_dir,
|
||||||
|
"camera")
|
||||||
|
else:
|
||||||
|
self.save_directory = self.config["save_directory"]
|
||||||
|
|
||||||
|
if not os.path.exists(self.save_directory):
|
||||||
|
os.makedirs(self.save_directory)
|
||||||
|
|
||||||
|
#global cam_led
|
||||||
|
#cam_led = LED(CAMERA_LED_PIN, active_high=False, initial_value=False)
|
||||||
|
|
||||||
|
for trigger in self.config["trigger"]:
|
||||||
|
self.shepherd.scheduler.add_job(self.camera_job, 'cron',
|
||||||
|
hour=trigger["hour"],
|
||||||
|
minute=trigger["minute"],
|
||||||
|
second=trigger["second"])
|
||||||
|
|
||||||
|
def setup_other_modules(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def camera_job(self):
|
||||||
|
self.interface.hook_pre_cam()
|
||||||
|
|
||||||
|
print("Running camera...")
|
||||||
|
stream = io.BytesIO()
|
||||||
|
with PiCamera() as picam:
|
||||||
|
picam.resolution = (3280, 2464)
|
||||||
|
picam.start_preview()
|
||||||
|
time.sleep(2)
|
||||||
|
picam.capture(stream, format='jpeg')
|
||||||
|
# "Rewind" the stream to the beginning so we can read its content
|
||||||
|
image_time = datetime.now()
|
||||||
|
|
||||||
|
stream.seek(0)
|
||||||
|
newimage = Image.open(stream)
|
||||||
|
try:
|
||||||
|
fnt = ImageFont.truetype(overlayfont, 50)
|
||||||
|
except IOError:
|
||||||
|
fnt = ImageFont.load_default()
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(newimage)
|
||||||
|
|
||||||
|
image_text = image_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
if self.config["append_id"]:
|
||||||
|
image_text = image_text + " " + self.shepherd.id
|
||||||
|
image_text = image_text + self.config["append_text"]
|
||||||
|
|
||||||
|
draw.text((50, newimage.height-100), image_text, font=fnt,
|
||||||
|
fill=(255, 255, 255, 200))
|
||||||
|
|
||||||
|
image_filename = image_time.strftime("%Y-%m-%d %H-%M-%S")
|
||||||
|
if self.config["append_id"]:
|
||||||
|
image_filename = image_filename + " " + self.shepherd.id
|
||||||
|
|
||||||
|
image_filename = image_filename + self.config["append_text"] + ".jpg"
|
||||||
|
image_filename = os.path.join(self.save_directory, image_filename)
|
||||||
|
newimage.save(image_filename+".writing", "JPEG")
|
||||||
|
os.rename(image_filename+".writing", image_filename)
|
||||||
|
|
||||||
|
if self.config["upload_images"]:
|
||||||
|
self.modules.uploader.move_to_bucket(image_filename,
|
||||||
|
self.config["upload_bucket"])
|
||||||
|
self.interface.hook_post_cam()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pass
|
||||||
|
# print("main")
|
||||||
|
# main(sys.argv[1:])
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import paramiko
|
||||||
|
import shepherd.config
|
||||||
|
import shepherd.module
|
||||||
|
# configdef = shepherd.config.definition()
|
||||||
|
|
||||||
|
# Can either import shepherd.config here, and call a function to build a config_def
|
||||||
|
# or can leave a config_def entry point.
|
||||||
|
# probably go with entry point, to stay consistent with the module
|
||||||
|
|
||||||
|
|
||||||
|
class UploaderConfDef(shepherd.config.ConfDefinition):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
dests = self.add_def('destination', shepherd.config.TableArrayDef())
|
||||||
|
dests.add_def('name', shepherd.config.StringDef())
|
||||||
|
dests.add_def('protocol', shepherd.config.StringDef())
|
||||||
|
dests.add_def('address', shepherd.config.StringDef(optional=True))
|
||||||
|
dests.add_def('port', shepherd.config.IntDef(optional=True))
|
||||||
|
dests.add_def('path', shepherd.config.StringDef(optional=True))
|
||||||
|
dests.add_def('username', shepherd.config.StringDef(optional=True))
|
||||||
|
dests.add_def('password', shepherd.config.StringDef(optional=True))
|
||||||
|
dests.add_def('keyfile', shepherd.config.StringDef(default="", optional=True))
|
||||||
|
dests.add_def('add_id_to_path', shepherd.config.BoolDef(default=True, optional=True))
|
||||||
|
|
||||||
|
buckets = self.add_def('bucket', shepherd.config.TableArrayDef())
|
||||||
|
buckets.add_def('name', shepherd.config.StringDef())
|
||||||
|
buckets.add_def('open_link_on_new', shepherd.config.BoolDef())
|
||||||
|
buckets.add_def('opportunistic', shepherd.config.BoolDef(default=True, optional=True))
|
||||||
|
buckets.add_def('keep_copy', shepherd.config.BoolDef())
|
||||||
|
buckets.add_def('destination', shepherd.config.StringDef())
|
||||||
|
|
||||||
|
|
||||||
|
# on server side, we want to be able to list commands that a module responds to
|
||||||
|
# without actually instantiating the module class. Add command templates into
|
||||||
|
# the conf_def, than attach to them in the interface? Was worried about having
|
||||||
|
# "two sources of truth", but you already need to match the conf_def to the
|
||||||
|
# name where you access the value in the module. Could have add_command, which
|
||||||
|
# you then add standard conf_def subclasses to, to reuse validation and server
|
||||||
|
# form generation logic...
|
||||||
|
|
||||||
|
|
||||||
|
class UploaderInterface(shepherd.module.Interface):
|
||||||
|
def __init__(self, module):
|
||||||
|
super().__init__(module)
|
||||||
|
# self.add_command("trigger", self.module.camera_job)
|
||||||
|
def _get_bucket_path(self, bucket_name):
|
||||||
|
return self._module.buckets[bucket_name].path
|
||||||
|
|
||||||
|
def move_to_bucket(self, filepath, bucket_name):
|
||||||
|
dest_path = os.path.join(self._get_bucket_path(bucket_name),
|
||||||
|
os.path.basename(filepath))
|
||||||
|
temp_dest_path = dest_path + ".writing"
|
||||||
|
shutil.move(filepath, temp_dest_path)
|
||||||
|
os.rename(temp_dest_path, dest_path)
|
||||||
|
|
||||||
|
# notify bucket to check for new files
|
||||||
|
self._module.buckets[bucket_name].newfile_event.set()
|
||||||
|
|
||||||
|
|
||||||
|
class Destination():
|
||||||
|
def __init__(self, config, core_interface):
|
||||||
|
self.config = config
|
||||||
|
self.shepherd = core_interface
|
||||||
|
self.sendlist_condition = threading.Condition()
|
||||||
|
self.send_list = []
|
||||||
|
|
||||||
|
self.thread = threading.Thread(target=self._send_files)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
# Override this in subclasses, implementing the actual upload process.
|
||||||
|
# Return true on success, false on failure.
|
||||||
|
def upload(self, filepath, suffix):
|
||||||
|
print ("Dummy uploading "+filepath)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_files_to_send(self, file_path_list):
|
||||||
|
self.sendlist_condition.acquire()
|
||||||
|
for file_path in file_path_list:
|
||||||
|
if file_path not in self.send_list:
|
||||||
|
self.send_list.append(file_path)
|
||||||
|
self.sendlist_condition.notify()
|
||||||
|
|
||||||
|
self.sendlist_condition.release()
|
||||||
|
|
||||||
|
def _file_available(self):
|
||||||
|
return len(self.send_list) > 0
|
||||||
|
|
||||||
|
def _send_files(self):
|
||||||
|
while True:
|
||||||
|
self.sendlist_condition.acquire()
|
||||||
|
# this drops through immediately if there is something to send, otherwise waits
|
||||||
|
self.sendlist_condition.wait_for(self._file_available)
|
||||||
|
file_to_send = self.send_list.pop(0)
|
||||||
|
os.rename(file_to_send, file_to_send+".uploading")
|
||||||
|
self.sendlist_condition.release()
|
||||||
|
|
||||||
|
# Rename uploaded file to end with ".uploaded" on success, or back
|
||||||
|
# to original path on failure.
|
||||||
|
try:
|
||||||
|
self.upload(file_to_send, ".uploading")
|
||||||
|
os.rename(file_to_send+".uploading", file_to_send+".uploaded")
|
||||||
|
except:
|
||||||
|
os.rename(file_to_send+".uploading", file_to_send)
|
||||||
|
self.send_list.append(file_to_send)
|
||||||
|
|
||||||
|
|
||||||
|
class SFTPDestination(Destination):
|
||||||
|
def upload(self, filepath, suffix):
|
||||||
|
with paramiko.Transport((self.config["address"],
|
||||||
|
self.config["port"])) as transport:
|
||||||
|
transport.connect(username=self.config["username"],
|
||||||
|
password=self.config["password"])
|
||||||
|
with paramiko.SFTPClient.from_transport(transport) as sftp:
|
||||||
|
print("Uploading "+filepath+" to "+self.config["address"]+" via SFTP")
|
||||||
|
if self.config["add_id_to_path"]:
|
||||||
|
destdir = os.path.join(self.config["path"],
|
||||||
|
self.shepherd.id)
|
||||||
|
else:
|
||||||
|
destdir = self.config["path"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
sftp.listdir(destdir)
|
||||||
|
except IOError:
|
||||||
|
print("Creating remot dir:" + destdir)
|
||||||
|
sftp.mkdir(destdir)
|
||||||
|
|
||||||
|
print("Target dir:"+destdir)
|
||||||
|
sftp.put(filepath+suffix,
|
||||||
|
os.path.join(destdir, os.path.basename(filepath)))
|
||||||
|
|
||||||
|
|
||||||
|
class Bucket():
|
||||||
|
def __init__(self, name, open_link_on_new, opportunistic, keep_copy,
|
||||||
|
destination, core_interface, path=None, old_path=None):
|
||||||
|
self.newfile_event = threading.Event()
|
||||||
|
self.newfile_event.set()
|
||||||
|
|
||||||
|
self.destination = destination
|
||||||
|
|
||||||
|
self.shepherd = core_interface
|
||||||
|
self.path = path
|
||||||
|
if self.path is None:
|
||||||
|
self.path = os.path.join(self.shepherd.root_dir, name)
|
||||||
|
if not os.path.exists(self.path):
|
||||||
|
os.makedirs(self.path)
|
||||||
|
|
||||||
|
if keep_copy:
|
||||||
|
self.old_path = old_path
|
||||||
|
if self.old_path is None:
|
||||||
|
self.old_path = os.path.join(self.shepherd.root_dir, name + "_old")
|
||||||
|
if not os.path.exists(self.old_path):
|
||||||
|
os.makedirs(self.old_path)
|
||||||
|
|
||||||
|
self.thread = threading.Thread(target=self._check_files)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def _check_files(self):
|
||||||
|
|
||||||
|
while True:
|
||||||
|
self.newfile_event.wait(timeout=10)
|
||||||
|
self.newfile_event.clear()
|
||||||
|
bucket_files = []
|
||||||
|
for item in os.listdir(self.path):
|
||||||
|
item_path = os.path.join(self.path, item)
|
||||||
|
if (os.path.isfile(item_path) and
|
||||||
|
(not item.endswith(".writing")) and
|
||||||
|
(not item.endswith(".uploading")) and
|
||||||
|
(not item.endswith(".uploaded"))):
|
||||||
|
bucket_files.append(item_path)
|
||||||
|
|
||||||
|
if bucket_files:
|
||||||
|
self.destination.add_files_to_send(bucket_files)
|
||||||
|
|
||||||
|
|
||||||
|
class UploaderModule(shepherd.module.Module):
|
||||||
|
conf_def = UploaderConfDef()
|
||||||
|
|
||||||
|
def __init__(self, config, core_interface):
|
||||||
|
super().__init__(config, core_interface)
|
||||||
|
|
||||||
|
print("Uploader config:")
|
||||||
|
print(self.config)
|
||||||
|
|
||||||
|
self.interface = UploaderInterface(self)
|
||||||
|
|
||||||
|
self.destinations = {}
|
||||||
|
self.buckets = {}
|
||||||
|
|
||||||
|
for dest_conf in self.config["destination"]:
|
||||||
|
if dest_conf["protocol"] == "sftp":
|
||||||
|
self.destinations[dest_conf["name"]] = SFTPDestination(dest_conf, core_interface)
|
||||||
|
else:
|
||||||
|
self.destinations[dest_conf["name"]] = Destination(dest_conf, core_interface)
|
||||||
|
|
||||||
|
for bucketconf in self.config["bucket"]:
|
||||||
|
bucketconf["destination"] = self.destinations[bucketconf["destination"]]
|
||||||
|
self.buckets[bucketconf["name"]] = Bucket(
|
||||||
|
**bucketconf, core_interface=self.shepherd)
|
||||||
|
|
||||||
|
def init_other_modules(self, interfaces): # pylint: disable=W0235
|
||||||
|
super().init_other_modules(interfaces)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pass
|
||||||
|
#print("main")
|
||||||
|
#main(sys.argv[1:])
|
||||||
Loading…
Reference in new issue