Skip to content
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Mycodo files
test/
tests/
!mycodo/tests/
site/
env/
.venv
/databases/flask_secret_key
/mycodo/config_override.py
/mycodo/flask_session/
*.db
*.pem
*.bak
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ sudo service mycodoflask restart
### Features

- Add API endpoints: /log and /dependency ([#1430](https://github.com/kizniche/Mycodo/issues/1430))
- Add ability to update (overwrite) a custom Function module without deleting it
- Add Input: ENS160 ([#1434](https://github.com/kizniche/Mycodo/pull/1434))
- Add Output: Waveshare 8-Channel Raspberry Pi Relay Board B ([#1434](https://github.com/kizniche/Mycodo/pull/1434))
- Add Function: Camera: rpicam ([#1487](https://github.com/kizniche/Mycodo/pull/1487))
Expand Down
6 changes: 6 additions & 0 deletions mycodo/mycodo_flask/forms/forms_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ class ControllerDel(FlaskForm):
delete_controller = SubmitField(TRANSLATIONS['delete']['title'])


class ControllerMod(FlaskForm):
controller_id = StringField(widget=widgets.HiddenInput())
update_controller_file = FileField()
update_controller = SubmitField(lazy_gettext('Replace'))


#
# Settings (Action)
#
Expand Down
6 changes: 5 additions & 1 deletion mycodo/mycodo_flask/routes_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def settings_function():

form_controller = forms_settings.Controller()
form_controller_delete = forms_settings.ControllerDel()
form_controller_update = forms_settings.ControllerMod()

# Get list of custom functions
excluded_files = ['__init__.py', '__pycache__']
Expand All @@ -134,6 +135,8 @@ def settings_function():
utils_settings.settings_function_import(form_controller)
elif form_controller_delete.delete_controller.data:
utils_settings.settings_function_delete(form_controller_delete)
elif form_controller_update.update_controller.data:
utils_settings.settings_function_update(form_controller_update)

return redirect(url_for('routes_settings.settings_function'))

Expand All @@ -155,7 +158,8 @@ def settings_function():
return render_template('settings/function.html',
dict_controllers=dict_controllers,
form_controller=form_controller,
form_controller_delete=form_controller_delete)
form_controller_delete=form_controller_delete,
form_controller_update=form_controller_update)


@blueprint.route('/settings/action', methods=('GET', 'POST'))
Expand Down
15 changes: 12 additions & 3 deletions mycodo/mycodo_flask/templates/settings/function.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,25 @@ <h3>{{_('Imported Function Modules')}}</h3>

{% for each_controller in dict_controllers %}

<form method="post" action="/settings/function">
<form method="post" action="/settings/function" enctype="multipart/form-data">
{{form_controller_delete.csrf_token}}
{{form_controller_delete.controller_id(value=each_controller)}}
{{form_controller_update.controller_id(value=each_controller)}}

Comment thread
kizniche marked this conversation as resolved.
Outdated
<tr>
<td>{{each_controller}}</td>
<td>{{dict_controllers[each_controller]['function_name']}}</td>
<td>
<div class="col-12 small-gutters">
{{form_controller_delete.delete_controller(class_='btn btn-primary btn-block', **{'onclick':'return confirm("Are you sure you want to delete this?")'})}}
<div class="row small-gutters">
<div class="col-auto">
{{form_controller_delete.delete_controller(class_='btn btn-primary btn-block', **{'onclick':'return confirm("Are you sure you want to delete this?")'})}}
</div>
<div class="col-auto">
<span class="btn btn-sm btn-file"><input id="update_controller_file_{{each_controller}}" name="update_controller_file" type="file" /></span>
</div>
<div class="col-auto">
{{form_controller_update.update_controller(class_='btn btn-primary btn-block', **{'onclick':'return confirm("Are you sure you want to update this? This will overwrite the existing module.")'})}}
</div>
</div>
</td>
</tr>
Expand Down
121 changes: 121 additions & 0 deletions mycodo/mycodo_flask/utils/utils_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,127 @@ def settings_function_delete(form):
flash_success_errors(error, action, url_for('routes_settings.settings_function'))


def settings_function_update(form):
"""
Receive a function module file, check it for errors, replace the existing module
"""
action = '{action} {controller}'.format(
action=gettext("Update"),
controller=TRANSLATIONS['controller']['title'])
error = []

controller_info = None
existing_controller_info = None

try:
install_dir = os.path.abspath(INSTALL_DIRECTORY)
tmp_directory = os.path.join(install_dir, 'mycodo/functions/tmp_functions')
assure_path_exists(tmp_directory)
assure_path_exists(PATH_FUNCTIONS_CUSTOM)
tmp_name = 'tmp_function_testing.py'
full_path_tmp = os.path.join(tmp_directory, tmp_name)

controller_device_name = form.controller_id.data

if not form.update_controller_file.data:
error.append('No file present')
elif form.update_controller_file.data.filename == '':
error.append('No file name')
else:
form.update_controller_file.data.save(full_path_tmp)

# Load and validate the uploaded (new) module
try:
controller_info, status = load_module_from_file(full_path_tmp, 'functions')
if not controller_info or not hasattr(controller_info, 'FUNCTION_INFORMATION'):
error.append("Could not load FUNCTION_INFORMATION dictionary from "
"the uploaded controller module")
except Exception:
error.append("Could not load uploaded file as a python module:\n"
"{}".format(traceback.format_exc()))

# Load the existing (old) module from disk to extract its function_name_unique
existing_file_path = os.path.join(
PATH_FUNCTIONS_CUSTOM, '{}.py'.format(controller_device_name.lower()))
try:
existing_controller_info, status = load_module_from_file(existing_file_path, 'functions')
if not existing_controller_info or not hasattr(existing_controller_info, 'FUNCTION_INFORMATION'):
error.append("Could not load FUNCTION_INFORMATION dictionary from "
"the existing controller module")
except Exception:
error.append("Could not load existing controller module as a python module:\n"
"{}".format(traceback.format_exc()))
Comment thread
kizniche marked this conversation as resolved.
Outdated

if not error:
if 'function_name_unique' not in controller_info.FUNCTION_INFORMATION:
error.append(
"'function_name_unique' not found in "
"FUNCTION_INFORMATION dictionary")
elif controller_info.FUNCTION_INFORMATION['function_name_unique'] == '':
error.append("'function_name_unique' is empty")
elif (controller_info.FUNCTION_INFORMATION['function_name_unique'].lower() !=
existing_controller_info.FUNCTION_INFORMATION['function_name_unique'].lower()):
error.append(
"'function_name_unique' must match the existing module name '{}', "
"but '{}' was found".format(
existing_controller_info.FUNCTION_INFORMATION['function_name_unique'],
controller_info.FUNCTION_INFORMATION['function_name_unique']))

if 'function_name' not in controller_info.FUNCTION_INFORMATION:
error.append("'function_name' not found in FUNCTION_INFORMATION dictionary")
elif controller_info.FUNCTION_INFORMATION['function_name'] == '':
error.append("'function_name' is empty")

if 'dependencies_module' in controller_info.FUNCTION_INFORMATION:
if not isinstance(controller_info.FUNCTION_INFORMATION['dependencies_module'], list):
error.append("'dependencies_module' must be a list of tuples")
else:
for each_dep in controller_info.FUNCTION_INFORMATION['dependencies_module']:
if not isinstance(each_dep, tuple):
error.append("'dependencies_module' must be a list of tuples")
elif len(each_dep) != 3:
error.append("'dependencies_module': tuples in list must have 3 items")
elif not each_dep[0] or not each_dep[1] or not each_dep[2]:
error.append(
"'dependencies_module': tuples in list must "
"not be empty")
elif each_dep[0] not in ['internal', 'pip-pypi', 'apt']:
error.append(
"'dependencies_module': first in tuple "
"must be 'internal', 'pip-pypi', "
"or 'apt'")

if not error:
# Determine filename from the uploaded module's function_name_unique
unique_name = '{}.py'.format(controller_info.FUNCTION_INFORMATION['function_name_unique'].lower())

# Move module from temp directory to function directory, overwriting the existing module
full_path_final = os.path.join(PATH_FUNCTIONS_CUSTOM, unique_name)
os.rename(full_path_tmp, full_path_final)
Comment thread
kizniche marked this conversation as resolved.

# Reload frontend to refresh the controllers
cmd = '{path}/mycodo/scripts/mycodo_wrapper frontend_reload 2>&1'.format(
path=install_dir)
subprocess.Popen(cmd, shell=True)
flash('Frontend reloaded to scan for updated Controller Modules', 'success')

# Restart the backend if any Controller using this module is currently activated
controller_activated = CustomController.query.filter(
CustomController.device == controller_device_name,
CustomController.is_activated == True).count()
Comment thread
kizniche marked this conversation as resolved.
Outdated
if controller_activated:
cmd = '{path}/mycodo/scripts/mycodo_wrapper daemon_restart 2>&1'.format(
path=install_dir)
subprocess.Popen(cmd, shell=True)
flash('Backend restarted to apply updated Controller Module', 'success')

except Exception as err:
logger.exception("Function Update")
error.append("Exception: {}".format(err))

flash_success_errors(error, action, url_for('routes_settings.settings_function'))


def settings_action_import(form):
"""
Receive an action module file, check it for errors, add it to Mycodo controller list
Expand Down
Loading
Loading