Init first version

Contains a basic web server, 1W sensor reading, relays actuator, a
working control algorithm and recipes loading
master
Nicolò Balzarotti 2022-11-19 18:52:01 +01:00
commit c45114f6f7
38 changed files with 31222 additions and 0 deletions

57
actuators.py Normal file
View File

@ -0,0 +1,57 @@
import atexit
try:
import RPi.GPIO as GPIO
GPIO.setmode(GPIO.BCM)
rpi = True
except:
rpi = False
class Actuator():
def __init__(self):
pass
class GPIOPin(Actuator):
def __init__(self, *args, **kwargs):
self.pin = (kwargs.pop('pin'))
self.initial = (kwargs.pop('initial'))
self.onstate = (kwargs.pop('onstate'))
self.offstate = (kwargs.pop('offstate'))
GPIO.setup(self.pin, GPIO.OUT, initial=self.initial)
atexit.register(self.deinit)
super().__init__(*args, **kwargs)
def deinit(self):
# Be sure everything is off at exit
print('Cleaning before exit')
GPIO.output(self.pin, self.initial)
GPIO.cleanup()
def enable(self, enable=True):
GPIO.output(self.pin, self.onstate) if enable else self.disable()
def disable(self):
GPIO.output(self.pin, self.offstate)
class MockPIN(Actuator):
def __init__(self, *args, **kwargs):
self.pin = (kwargs.pop('pin'))
self.initial = (kwargs.pop('initial'))
self.onstate = (kwargs.pop('onstate'))
self.offstate = (kwargs.pop('offstate'))
super().__init__(*args, **kwargs)
def enable(self, enable=True):
print('FAKE ENABLE') if enable else self.disable()
def disable(self):
print('FAKE DISABLE')
actuators = {
'heater': GPIOPin(
pin=22, initial=GPIO.HIGH, onstate=GPIO.LOW, offstate=GPIO.HIGH) if (
rpi) else MockPIN(pin=22, initial=False, onstate=True, offstate=False),
}

113
app.py Normal file
View File

@ -0,0 +1,113 @@
import json
from flask import Flask, Response
from flask import render_template, render_template_string, escape
from flask import request, send_file
from flask_socketio import SocketIO, join_room
from random import randint
from urllib.parse import urlparse, urlunparse
from os.path import exists
from phasectrl import State, ManualIntervention
from sensors import sensors
import phasectrl
from time import perf_counter, sleep
def create_app():
app = Flask('hakkoso')
app.config['DATABASE'] = 'server.db'
return app
app = create_app()
socketio = SocketIO(app, async_mode='threading')
def updateState():
socketio.emit('state', make_state())
print('SENT STATE UPDATE', make_state())
statemachine = State('recipes.json', updateState)
def make_state():
return {
'manual': statemachine.manual.getState(),
'recipes': tuple(r.getState() for r in statemachine.recipes),
'recipe': (statemachine.recipe.getState(statemachine.phase)
if statemachine.recipe else None),
'state': statemachine.getState(),
'sensors': {
'units': {
'Temperature': '°C',
'Acidity': 'pH',
'Humidity': 'RH',
'Durezza Acqua': 'BOH',
},
'found': sensors.list()
}
}
@app.route('/')
def index():
return render_template('index.html')
@app.route('/recipe-editor')
def recipe_editor():
return render_template('recipe-editor.html')
@app.route('/status')
def status():
return make_state()
@app.route('/dist/<filename>')
def dist(filename):
# TODO: check file exists and path is under src
filename = 'dist/' + filename
if exists(filename):
return send_file(filename)
print('Missing', filename)
return ''
@app.route('/recipe')
def recipe():
return render_template('recipe.html', recipe=phasectrl.recipe)
@socketio.on('new client')
def handle_new_client():
updateState()
socketio.emit('sensor history', sensors.get_history())
@socketio.on('manual response')
def handle_manual_response(response):
if statemachine.recipe is not None:
statemachine.manual.set(response)
updateState()
@socketio.on('load recipe idx')
def load_recipe_idx(idx):
statemachine.loadByIdx(idx)
def run_recipes():
while True:
while statemachine.recipe is not None:
done = statemachine.run_step()
if done:
statemachine.done()
updateState()
sleep(1)
def read_esnsors():
while True:
sensors.read()
socketio.emit('sensors', (sensors.get(),))
sleep(0.5)
import threading
if __name__ == '__main__':
thread = threading.Thread(target=run_recipes, daemon=True)
thread.start()
sensors_thread = threading.Thread(target=read_esnsors, daemon=True)
sensors_thread.start()
print('RUN APP')
socketio.run(app)

39
chat.py Normal file
View File

@ -0,0 +1,39 @@
from nio import AsyncClient # pip3 install matrix-nio
import asyncio
class Chatter():
def __init__(self, homeserver, defaultroom):
self.homeserver = homeserver
self.defaultroom = defaultroom
def login(self, username, device, token):
self.client = AsyncClient(self.homeserver, username)
self.client.restore_login(
user_id=username,
device_id=device,
access_token = token)
async def send(self, message, room=None):
print(f"Sending {message}")
await self.client.room_send(room_id = room or self.defaultroom,
message_type="m.room.message",
content = {
"msgtype": "m.text",
"body": message
}
)
await self.client.close()
def send_sync(self, msg):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
coroutine = chat.send(msg)
loop.run_until_complete(coroutine)
chat = Chatter('https://chat.nixo.xyz', '!zJiIjYrICcdvRPKuen:nixo.xyz')
chat.login(
'@rabbit:nixo.xyz', 'ESJZVKGHVF',
'MDAxNmxvY2F0aW9uIG5peG8ueHl6CjAwMTNpZGVudGlmaWVyIGtleQowMDEwY2lkIGdlbiA9IDEKMDAyM2NpZCB1c2VyX2lkID0gQHJhYmJpdDpuaXhvLnh5egowMDE2Y2lkIHR5cGUgPSBhY2Nlc3MKMDAyMWNpZCBub25jZSA9IDUjbUAjTHdlYjpxeEd3MlYKMDAyZnNpZ25hdHVyZSBoyKyldyQxYejjrznz_M47h6TQFxCv3PxpcESV0OnGago')

3
control/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .controllers import *
from .fixed_duty import *
from .feedforward_duty import *

48
control/controllers.py Normal file
View File

@ -0,0 +1,48 @@
from time import perf_counter
class Controller():
def __init__(self,
# Actions
# Target value we want to reach, in what period ()
target=None, reach_period_s=None,
# Timeframe we need to wait before changing the
# system output (means: how fast we can trigger
# on/off states). Used to prevent breaking fridge or
# other actuators.
refractary_period_s=10,
# Send notifications outside this range?
warning_range=(None, None)):
self.input = None
self.target = target
self.range = warning_range
self.reach_period_s = reach_period_s
self.refractary_period_s = refractary_period_s
def set_reach_period(self, reach_period_s):
self.reach_period_s = reach_period_s
def set_input(self, input):
self.input = input
def set_input_label(self, label):
self.input_label = label
def set_target(self, target):
print('Setting target', target)
self.target = target
def set_warning_range(self, target):
self.target = target
def one_cycle(self, time=None):
print("ERROR: This function must be implemented by the controller")
"Set the input, run `one_cycle' and return the required actuator action."
def apply(self, input, time=perf_counter):
# Set the current input
self.set_input(input)
# Call one cycle
action = self.one_cycle(time())
# TODO: Check refractary period?
return action

View File

@ -0,0 +1,6 @@
from . import controllers
class FeedforwardDutyCycle(controllers.Controller):
def one_cycle(self, time):
print("FIXME: implement this!")

46
control/fixed_duty.py Normal file
View File

@ -0,0 +1,46 @@
from . import controllers
from scm import stringify
from time import perf_counter
class FixedDutyCycle(controllers.Controller):
def __init__(self, *args, **kwargs):
self.start = kwargs.pop('start_time', perf_counter())
self.period = kwargs.pop('period')
self.set_duty(kwargs.pop('duty_perc'))
super().__init__(*args, **kwargs)
self.last_time = None
def set_duty(self, duty=None):
# Updates the duty cycle to a point in time where the switch
# must occur
if duty is not None:
self.duty_perc = duty
self.duty = self.duty_perc/100 * self.period
def set_period(self, period):
# Shift start point so that we know where in the new period we are.
# Then, move period
# "|-----X----------------|" punto % old_period
# "|-----X----|-----------|" start = now - (punto % old_period)
# "|----|X----|-----|-----|"
self.start = self.last_time - (self.timepoint() % self.period)
print(period, self.start)
self.period = period
self.set_duty()
def timepoint(self):
return ((self.last_time - self.start) % self.period)
def one_cycle(self, time):
self.last_time = time
return (self.timepoint() < self.duty) if (
self.input < self.target) else False
def fixed_duty_wrapper(args):
name, duty, period = stringify(args.car, quote=False), args.cdr.car, args.cdr.cdr.car
print('name, duty, period', name, duty, period)
return (name, FixedDutyCycle(
target=None, # reach_period_s=60*60,
duty_perc=duty*100, period=period,
start_time=perf_counter()))

11851
dist/bulma-rtl.css vendored Normal file

File diff suppressed because it is too large Load Diff

1
dist/bulma-rtl.css.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/bulma-rtl.min.css vendored Normal file

File diff suppressed because one or more lines are too long

11851
dist/bulma.css vendored Normal file

File diff suppressed because it is too large Load Diff

1
dist/bulma.css.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/bulma.min.css vendored Normal file

File diff suppressed because one or more lines are too long

37
dist/client-side-templates.js vendored Normal file
View File

@ -0,0 +1,37 @@
htmx.defineExtension('client-side-templates', {
transformResponse : function(text, xhr, elt) {
var mustacheTemplate = htmx.closest(elt, "[mustache-template]");
if (mustacheTemplate) {
var data = JSON.parse(text);
var templateId = mustacheTemplate.getAttribute('mustache-template');
var template = htmx.find("#" + templateId);
if (template) {
return Mustache.render(template.innerHTML, data);
} else {
throw "Unknown mustache template: " + templateId;
}
}
var handlebarsTemplate = htmx.closest(elt, "[handlebars-template]");
if (handlebarsTemplate) {
var data = JSON.parse(text);
var templateName = handlebarsTemplate.getAttribute('handlebars-template');
return Handlebars.partials[templateName](data);
}
var nunjucksTemplate = htmx.closest(elt, "[nunjucks-template]");
if (nunjucksTemplate) {
var data = JSON.parse(text);
var templateName = nunjucksTemplate.getAttribute('nunjucks-template');
var template = htmx.find('#' + templateName);
if (template) {
return nunjucks.renderString(template.innerHTML, data);
} else {
return nunjucks.render(templateName, data);
}
}
return text;
}
});

4
dist/font-awesome.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/htmx.org@1.8.4.js vendored Normal file

File diff suppressed because one or more lines are too long

772
dist/mustache.js vendored Normal file
View File

@ -0,0 +1,772 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Mustache = factory());
}(this, (function () { 'use strict';
/*!
* mustache.js - Logic-less {{mustache}} templates with JavaScript
* http://github.com/janl/mustache.js
*/
var objectToString = Object.prototype.toString;
var isArray = Array.isArray || function isArrayPolyfill (object) {
return objectToString.call(object) === '[object Array]';
};
function isFunction (object) {
return typeof object === 'function';
}
/**
* More correct typeof string handling array
* which normally returns typeof 'object'
*/
function typeStr (obj) {
return isArray(obj) ? 'array' : typeof obj;
}
function escapeRegExp (string) {
return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&');
}
/**
* Null safe way of checking whether or not an object,
* including its prototype, has a given property
*/
function hasProperty (obj, propName) {
return obj != null && typeof obj === 'object' && (propName in obj);
}
/**
* Safe way of detecting whether or not the given thing is a primitive and
* whether it has the given property
*/
function primitiveHasOwnProperty (primitive, propName) {
return (
primitive != null
&& typeof primitive !== 'object'
&& primitive.hasOwnProperty
&& primitive.hasOwnProperty(propName)
);
}
// Workaround for https://issues.apache.org/jira/browse/COUCHDB-577
// See https://github.com/janl/mustache.js/issues/189
var regExpTest = RegExp.prototype.test;
function testRegExp (re, string) {
return regExpTest.call(re, string);
}
var nonSpaceRe = /\S/;
function isWhitespace (string) {
return !testRegExp(nonSpaceRe, string);
}
var entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
function escapeHtml (string) {
return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) {
return entityMap[s];
});
}
var whiteRe = /\s*/;
var spaceRe = /\s+/;
var equalsRe = /\s*=/;
var curlyRe = /\s*\}/;
var tagRe = /#|\^|\/|>|\{|&|=|!/;
/**
* Breaks up the given `template` string into a tree of tokens. If the `tags`
* argument is given here it must be an array with two string values: the
* opening and closing tags used in the template (e.g. [ "<%", "%>" ]). Of
* course, the default is to use mustaches (i.e. mustache.tags).
*
* A token is an array with at least 4 elements. The first element is the
* mustache symbol that was used inside the tag, e.g. "#" or "&". If the tag
* did not contain a symbol (i.e. {{myValue}}) this element is "name". For
* all text that appears outside a symbol this element is "text".
*
* The second element of a token is its "value". For mustache tags this is
* whatever else was inside the tag besides the opening symbol. For text tokens
* this is the text itself.
*
* The third and fourth elements of the token are the start and end indices,
* respectively, of the token in the original template.
*
* Tokens that are the root node of a subtree contain two more elements: 1) an
* array of tokens in the subtree and 2) the index in the original template at
* which the closing tag for that section begins.
*
* Tokens for partials also contain two more elements: 1) a string value of
* indendation prior to that tag and 2) the index of that tag on that line -
* eg a value of 2 indicates the partial is the third tag on this line.
*/
function parseTemplate (template, tags) {
if (!template)
return [];
var lineHasNonSpace = false;
var sections = []; // Stack to hold section tokens
var tokens = []; // Buffer to hold the tokens
var spaces = []; // Indices of whitespace tokens on the current line
var hasTag = false; // Is there a {{tag}} on the current line?
var nonSpace = false; // Is there a non-space char on the current line?
var indentation = ''; // Tracks indentation for tags that use it
var tagIndex = 0; // Stores a count of number of tags encountered on a line
// Strips all whitespace tokens array for the current line
// if there was a {{#tag}} on it and otherwise only space.
function stripSpace () {
if (hasTag && !nonSpace) {
while (spaces.length)
delete tokens[spaces.pop()];
} else {
spaces = [];
}
hasTag = false;
nonSpace = false;
}
var openingTagRe, closingTagRe, closingCurlyRe;
function compileTags (tagsToCompile) {
if (typeof tagsToCompile === 'string')
tagsToCompile = tagsToCompile.split(spaceRe, 2);
if (!isArray(tagsToCompile) || tagsToCompile.length !== 2)
throw new Error('Invalid tags: ' + tagsToCompile);
openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*');
closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1]));
closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1]));
}
compileTags(tags || mustache.tags);
var scanner = new Scanner(template);
var start, type, value, chr, token, openSection;
while (!scanner.eos()) {
start = scanner.pos;
// Match any text between tags.
value = scanner.scanUntil(openingTagRe);
if (value) {
for (var i = 0, valueLength = value.length; i < valueLength; ++i) {
chr = value.charAt(i);
if (isWhitespace(chr)) {
spaces.push(tokens.length);
indentation += chr;
} else {
nonSpace = true;
lineHasNonSpace = true;
indentation += ' ';
}
tokens.push([ 'text', chr, start, start + 1 ]);
start += 1;
// Check for whitespace on the current line.
if (chr === '\n') {
stripSpace();
indentation = '';
tagIndex = 0;
lineHasNonSpace = false;
}
}
}
// Match the opening tag.
if (!scanner.scan(openingTagRe))
break;
hasTag = true;
// Get the tag type.
type = scanner.scan(tagRe) || 'name';
scanner.scan(whiteRe);
// Get the tag value.
if (type === '=') {
value = scanner.scanUntil(equalsRe);
scanner.scan(equalsRe);
scanner.scanUntil(closingTagRe);
} else if (type === '{') {
value = scanner.scanUntil(closingCurlyRe);
scanner.scan(curlyRe);
scanner.scanUntil(closingTagRe);
type = '&';
} else {
value = scanner.scanUntil(closingTagRe);
}
// Match the closing tag.
if (!scanner.scan(closingTagRe))
throw new Error('Unclosed tag at ' + scanner.pos);
if (type == '>') {
token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ];
} else {
token = [ type, value, start, scanner.pos ];
}
tagIndex++;
tokens.push(token);
if (type === '#' || type === '^') {
sections.push(token);
} else if (type === '/') {
// Check section nesting.
openSection = sections.pop();
if (!openSection)
throw new Error('Unopened section "' + value + '" at ' + start);
if (openSection[1] !== value)
throw new Error('Unclosed section "' + openSection[1] + '" at ' + start);
} else if (type === 'name' || type === '{' || type === '&') {
nonSpace = true;
} else if (type === '=') {
// Set the tags for the next time around.
compileTags(value);
}
}
stripSpace();
// Make sure there are no open sections when we're done.
openSection = sections.pop();
if (openSection)
throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos);
return nestTokens(squashTokens(tokens));
}
/**
* Combines the values of consecutive text tokens in the given `tokens` array
* to a single token.
*/
function squashTokens (tokens) {
var squashedTokens = [];
var token, lastToken;
for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
token = tokens[i];
if (token) {
if (token[0] === 'text' && lastToken && lastToken[0] === 'text') {
lastToken[1] += token[1];
lastToken[3] = token[3];
} else {
squashedTokens.push(token);
lastToken = token;
}
}
}
return squashedTokens;
}
/**
* Forms the given array of `tokens` into a nested tree structure where
* tokens that represent a section have two additional items: 1) an array of
* all tokens that appear in that section and 2) the index in the original
* template that represents the end of that section.
*/
function nestTokens (tokens) {
var nestedTokens = [];
var collector = nestedTokens;
var sections = [];
var token, section;
for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
token = tokens[i];
switch (token[0]) {
case '#':
case '^':
collector.push(token);
sections.push(token);
collector = token[4] = [];
break;
case '/':
section = sections.pop();
section[5] = token[2];
collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;
break;
default:
collector.push(token);
}
}
return nestedTokens;
}
/**
* A simple string scanner that is used by the template parser to find
* tokens in template strings.
*/
function Scanner (string) {
this.string = string;
this.tail = string;
this.pos = 0;
}
/**
* Returns `true` if the tail is empty (end of string).
*/
Scanner.prototype.eos = function eos () {
return this.tail === '';
};
/**
* Tries to match the given regular expression at the current position.
* Returns the matched text if it can match, the empty string otherwise.
*/
Scanner.prototype.scan = function scan (re) {
var match = this.tail.match(re);
if (!match || match.index !== 0)
return '';
var string = match[0];
this.tail = this.tail.substring(string.length);
this.pos += string.length;
return string;
};
/**
* Skips all text until the given regular expression can be matched. Returns
* the skipped string, which is the entire tail if no match can be made.
*/
Scanner.prototype.scanUntil = function scanUntil (re) {
var index = this.tail.search(re), match;
switch (index) {
case -1:
match = this.tail;
this.tail = '';
break;
case 0:
match = '';
break;
default:
match = this.tail.substring(0, index);
this.tail = this.tail.substring(index);
}
this.pos += match.length;
return match;
};
/**
* Represents a rendering context by wrapping a view object and
* maintaining a reference to the parent context.
*/
function Context (view, parentContext) {
this.view = view;
this.cache = { '.': this.view };
this.parent = parentContext;
}
/**
* Creates a new context using the given view with this context
* as the parent.
*/
Context.prototype.push = function push (view) {
return new Context(view, this);
};
/**
* Returns the value of the given name in this context, traversing
* up the context hierarchy if the value is absent in this context's view.
*/
Context.prototype.lookup = function lookup (name) {
var cache = this.cache;
var value;
if (cache.hasOwnProperty(name)) {
value = cache[name];
} else {
var context = this, intermediateValue, names, index, lookupHit = false;
while (context) {
if (name.indexOf('.') > 0) {
intermediateValue = context.view;
names = name.split('.');
index = 0;
/**
* Using the dot notion path in `name`, we descend through the
* nested objects.
*
* To be certain that the lookup has been successful, we have to
* check if the last object in the path actually has the property
* we are looking for. We store the result in `lookupHit`.
*
* This is specially necessary for when the value has been set to
* `undefined` and we want to avoid looking up parent contexts.
*
* In the case where dot notation is used, we consider the lookup
* to be successful even if the last "object" in the path is
* not actually an object but a primitive (e.g., a string, or an
* integer), because it is sometimes useful to access a property
* of an autoboxed primitive, such as the length of a string.
**/
while (intermediateValue != null && index < names.length) {
if (index === names.length - 1)
lookupHit = (
hasProperty(intermediateValue, names[index])
|| primitiveHasOwnProperty(intermediateValue, names[index])
);
intermediateValue = intermediateValue[names[index++]];
}
} else {
intermediateValue = context.view[name];
/**
* Only checking against `hasProperty`, which always returns `false` if
* `context.view` is not an object. Deliberately omitting the check
* against `primitiveHasOwnProperty` if dot notation is not used.
*
* Consider this example:
* ```
* Mustache.render("The length of a football field is {{#length}}{{length}}{{/length}}.", {length: "100 yards"})
* ```
*
* If we were to check also against `primitiveHasOwnProperty`, as we do
* in the dot notation case, then render call would return:
*
* "The length of a football field is 9."
*
* rather than the expected:
*
* "The length of a football field is 100 yards."
**/
lookupHit = hasProperty(context.view, name);
}
if (lookupHit) {
value = intermediateValue;
break;
}
context = context.parent;
}
cache[name] = value;
}
if (isFunction(value))
value = value.call(this.view);
return value;
};
/**
* A Writer knows how to take a stream of tokens and render them to a
* string, given a context. It also maintains a cache of templates to
* avoid the need to parse the same template twice.
*/
function Writer () {
this.templateCache = {
_cache: {},
set: function set (key, value) {
this._cache[key] = value;
},
get: function get (key) {
return this._cache[key];
},
clear: function clear () {
this._cache = {};
}
};
}
/**
* Clears all cached templates in this writer.
*/
Writer.prototype.clearCache = function clearCache () {
if (typeof this.templateCache !== 'undefined') {
this.templateCache.clear();
}
};
/**
* Parses and caches the given `template` according to the given `tags` or
* `mustache.tags` if `tags` is omitted, and returns the array of tokens
* that is generated from the parse.
*/
Writer.prototype.parse = function parse (template, tags) {
var cache = this.templateCache;
var cacheKey = template + ':' + (tags || mustache.tags).join(':');
var isCacheEnabled = typeof cache !== 'undefined';
var tokens = isCacheEnabled ? cache.get(cacheKey) : undefined;
if (tokens == undefined) {
tokens = parseTemplate(template, tags);
isCacheEnabled && cache.set(cacheKey, tokens);
}
return tokens;
};
/**
* High-level method that is used to render the given `template` with
* the given `view`.
*
* The optional `partials` argument may be an object that contains the
* names and templates of partials that are used in the template. It may
* also be a function that is used to load partial templates on the fly
* that takes a single argument: the name of the partial.
*
* If the optional `config` argument is given here, then it should be an
* object with a `tags` attribute or an `escape` attribute or both.
* If an array is passed, then it will be interpreted the same way as
* a `tags` attribute on a `config` object.
*
* The `tags` attribute of a `config` object must be an array with two
* string values: the opening and closing tags used in the template (e.g.
* [ "<%", "%>" ]). The default is to mustache.tags.
*
* The `escape` attribute of a `config` object must be a function which
* accepts a string as input and outputs a safely escaped string.
* If an `escape` function is not provided, then an HTML-safe string
* escaping function is used as the default.
*/
Writer.prototype.render = function render (template, view, partials, config) {
var tags = this.getConfigTags(config);
var tokens = this.parse(template, tags);
var context = (view instanceof Context) ? view : new Context(view, undefined);
return this.renderTokens(tokens, context, partials, template, config);
};
/**
* Low-level method that renders the given array of `tokens` using
* the given `context` and `partials`.
*
* Note: The `originalTemplate` is only ever used to extract the portion
* of the original template that was contained in a higher-order section.
* If the template doesn't use higher-order sections, this argument may
* be omitted.
*/
Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate, config) {
var buffer = '';
var token, symbol, value;
for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
value = undefined;
token = tokens[i];
symbol = token[0];
if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate, config);
else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate, config);
else if (symbol === '>') value = this.renderPartial(token, context, partials, config);
else if (symbol === '&') value = this.unescapedValue(token, context);
else if (symbol === 'name') value = this.escapedValue(token, context, config);
else if (symbol === 'text') value = this.rawValue(token);
if (value !== undefined)
buffer += value;
}
return buffer;
};
Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate, config) {
var self = this;
var buffer = '';
var value = context.lookup(token[1]);
// This function is used to render an arbitrary template
// in the current context by higher-order sections.
function subRender (template) {
return self.render(template, context, partials, config);
}
if (!value) return;
if (isArray(value)) {
for (var j = 0, valueLength = value.length; j < valueLength; ++j) {
buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate, config);
}
} else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') {
buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate, config);
} else if (isFunction(value)) {
if (typeof originalTemplate !== 'string')
throw new Error('Cannot use higher-order sections without the original template');
// Extract the portion of the original template that the section contains.
value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender);
if (value != null)
buffer += value;
} else {
buffer += this.renderTokens(token[4], context, partials, originalTemplate, config);
}
return buffer;
};
Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate, config) {
var value = context.lookup(token[1]);
// Use JavaScript's definition of falsy. Include empty arrays.
// See https://github.com/janl/mustache.js/issues/186
if (!value || (isArray(value) && value.length === 0))
return this.renderTokens(token[4], context, partials, originalTemplate, config);
};
Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) {
var filteredIndentation = indentation.replace(/[^ \t]/g, '');
var partialByNl = partial.split('\n');
for (var i = 0; i < partialByNl.length; i++) {
if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) {
partialByNl[i] = filteredIndentation + partialByNl[i];
}
}
return partialByNl.join('\n');
};
Writer.prototype.renderPartial = function renderPartial (token, context, partials, config) {
if (!partials) return;
var tags = this.getConfigTags(config);
var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
if (value != null) {
var lineHasNonSpace = token[6];
var tagIndex = token[5];
var indentation = token[4];
var indentedValue = value;
if (tagIndex == 0 && indentation) {
indentedValue = this.indentPartial(value, indentation, lineHasNonSpace);
}
var tokens = this.parse(indentedValue, tags);
return this.renderTokens(tokens, context, partials, indentedValue, config);
}
};
Writer.prototype.unescapedValue = function unescapedValue (token, context) {
var value = context.lookup(token[1]);
if (value != null)
return value;
};
Writer.prototype.escapedValue = function escapedValue (token, context, config) {
var escape = this.getConfigEscape(config) || mustache.escape;
var value = context.lookup(token[1]);
if (value != null)
return (typeof value === 'number' && escape === mustache.escape) ? String(value) : escape(value);
};
Writer.prototype.rawValue = function rawValue (token) {
return token[1];
};
Writer.prototype.getConfigTags = function getConfigTags (config) {
if (isArray(config)) {
return config;
}
else if (config && typeof config === 'object') {
return config.tags;
}
else {
return undefined;
}
};
Writer.prototype.getConfigEscape = function getConfigEscape (config) {
if (config && typeof config === 'object' && !isArray(config)) {
return config.escape;
}
else {
return undefined;
}
};
var mustache = {
name: 'mustache.js',
version: '4.2.0',
tags: [ '{{', '}}' ],
clearCache: undefined,
escape: undefined,
parse: undefined,
render: undefined,
Scanner: undefined,
Context: undefined,
Writer: undefined,
/**
* Allows a user to override the default caching strategy, by providing an
* object with set, get and clear methods. This can also be used to disable
* the cache by setting it to the literal `undefined`.
*/
set templateCache (cache) {
defaultWriter.templateCache = cache;
},
/**
* Gets the default or overridden caching object from the default writer.
*/
get templateCache () {
return defaultWriter.templateCache;
}
};
// All high-level mustache.* functions use this writer.
var defaultWriter = new Writer();
/**
* Clears all cached templates in the default writer.
*/
mustache.clearCache = function clearCache () {
return defaultWriter.clearCache();
};
/**
* Parses and caches the given template in the default writer and returns the
* array of tokens it contains. Doing this ahead of time avoids the need to
* parse templates on the fly as they are rendered.
*/
mustache.parse = function parse (template, tags) {
return defaultWriter.parse(template, tags);
};
/**
* Renders the `template` with the given `view`, `partials`, and `config`
* using the default writer.
*/
mustache.render = function render (template, view, partials, config) {
if (typeof template !== 'string') {
throw new TypeError('Invalid template! Template should be a "string" ' +
'but "' + typeStr(template) + '" was given as the first ' +
'argument for mustache#render(template, view, partials)');
}
return defaultWriter.render(template, view, partials, config);
};
// Export the escaping function so that the user may override it.
// See https://github.com/janl/mustache.js/issues/244
mustache.escape = escapeHtml;
// Export these mainly for testing, but also for advanced usage.
mustache.Scanner = Scanner;
mustache.Context = Context;
mustache.Writer = Writer;
return mustache;
})));

65
dist/plotly-2.16.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2145
dist/require.js vendored Normal file

File diff suppressed because it is too large Load Diff

302
dist/server.js vendored Normal file
View File

@ -0,0 +1,302 @@
var state = {};
var localstate = {
manual: {}, recipe: 0, page: ['General', 'Dashboard'],
maxpoints: 1000
};
var menu_items = [
{name: "General",
subitems:[
{name: "Dashboard"},
{name: "Sensors & Plots"}
]
},
{name: "Recipes",
subitems:[
{name: "Recipes Editor", link: 'recipe-editor'},
{name: "New Recipe"}
]
},
{name: "Configuration",
subitems:[
{name: "Settings"}
]
}
];
function applyState(newstate) {
if (newstate !== undefined) {
state = JSON.parse(JSON.stringify(newstate));
}
// preprocess
state.manual["dismissed"] = localstate.manual["dismissed"];
let selected_recipe = null;
for (let i = 0; i < state.recipes.length; i++) {
state.recipes[i].idx = i;
if (localstate.recipe == i) selected_recipe = state.recipes[i];
state.recipes[i].selected = localstate.recipe == i;
}
state.selected_recipe = JSON.parse(JSON.stringify(selected_recipe));
for (let i = 0; i < menu_items.length; i++) {
let group_match = menu_items[i].name == localstate.page[0];
for (let sub = 0; sub < menu_items[i].subitems.length; sub++) {
let sub_match = menu_items[i].subitems[sub].name == localstate.page[1];
menu_items[i].subitems[sub].current = group_match && sub_match;
menu_items[i].subitems[sub].path = JSON.stringify([
menu_items[i].name, menu_items[i].subitems[sub].name]);
}
}
state.menu_items = menu_items;
console.log('Apply state '+JSON.stringify(state));
// apply
render_sidebar(state);
manual_modal(state.manual);
current_recipe(state.recipe);
render_load_recipe(state);
render_plot();
}
var enable_draw = true;
var plot_data = {};
function add_plot_data(newpoints, render=true) {
for (var i = 0; i < newpoints.length; ++i) {
let point = newpoints[i]
let key = point[0];
if (!(key in plot_data)) {
plot_data[key] = {
x: [],
y: [],
type: 'scatter',
name: key
}
}
plot_data[key].x.push(point[1]);
plot_data[key].y.push(point[2]);
if (plot_data[key].x.length > localstate.maxpoints) {
plot_data[key].x.shift();
plot_data[key].y.shift();
}
}
if (render) render_plot();
}
function render_plot() {
if (enable_draw && document.getElementById("data-plot") !== null) {
Plotly.newPlot('data-plot', Object.values(plot_data));
}
}
function render_sensors(sensordata) {
let html = document.getElementById("sensor-list");
if (state.sensors === undefined || html === null) return;
let template = `<div class="content">
{{#sensors}}
<h4>{{what}}</h4>
{{^data}}No sensors found{{/data}}
<ul>
{{#data}}
<li ondblclick="alert('rename')">{{0}} -- {{1}} {{unit}}</li>
{{/data}}
</ul>
{{/sensors}}
</div>`;
let data = [];
for (let i = 0, k = Object.keys(state.sensors.units); i < k.length; ++i) {
let what = k[i];
let unit = state.sensors.units[k[i]];
let sensors = Object.keys(
Object.keys(state.sensors.found).reduce(function (filtered, key) {
if (state.sensors.found[key] == what) filtered[key] = state.sensors.found[key];
return filtered;
}, {}));
let req_data = [];
for (let j = 0; j < sensordata.length; ++j) {
if (sensors.includes(sensordata[j][0])) {
req_data.push([sensordata[j][0], sensordata[j][2]]);
}
}
data.push({what: what, unit: unit, data: req_data});
}
console.log({sensors:data});
html.innerHTML = Mustache.render(template, {sensors:data});
}
function navigate(path, link) {
console.log(link);
localstate.page = path;
applyState();
}
function render_sidebar(data) {
let html = document.getElementById("sidebar");
let template = `<aside class="menu">
{{#menu_items}}
<p class="menu-label">{{name}}</p>
<ul class="menu-list">
{{#subitems}}
<li><a onclick="navigate({{path}}, {{link}})"
class="{{#current}}is-active{{/current}}">{{name}}</a></li>
{{/subitems}}
</ul>
{{/menu_items}}
</aside>`
html.innerHTML = Mustache.render(template, data);
}
function respond_manual(response) {
socket.emit('manual response', response);
}
function select_recipe(idx) {
localstate.recipe = idx;
applyState();
}
function load_recipe() {
socket.emit('load recipe idx', localstate.recipe);
}
function dismiss_modal() {
applyState(state);
}
function render_load_recipe(data) {
let html = document.getElementById("load-recipe");
if (html === null) return;
let template = `<div class="card">
<header class="card-header">
<p class="card-header-title">
Load Recipe
</p>
</header>
<div class="card-content">
<div class="tabs is-centered">
<ul>
{{#recipes}}
<li class="{{#selected}}is-active{{/selected}}">
<a onclick="select_recipe({{idx}})">{{name}}</a>
</li>
{{/recipes}}
</ul>
</div>
{{#selected_recipe}}
{{description}}
{{#phases}}
<br><br>
<div class="content"><h4>{{name}}</h4></div>
{{text}}<br>
Ranges: FIXME<br>
On Load: {{onload}}<br>
Exit When: {{nextcond}}<br>
On Exit: {{onexit}}<br>
{{/phases}}
{{/selected_recipe}}
</div>
<footer class="card-footer">
<a onclick="load_recipe()"
class="card-footer-item is-primary">Load</a>
<a class="card-footer-item">Edit</a>
</footer>
</div>`
html.innerHTML = Mustache.render(template, data);
}
function current_recipe(data) {
let html = document.getElementById("current-recipe");
if (html === null) return;
if (data === null) {
html.innerHTML = '';
return;
}
let template = `
<div class="card">
<header class="card-header">
<p class="card-header-title">
Active Recipe: {{name}}
</p>
</header>
<div class="card-content">
{{description}}
<div class="tabs is-centered">
<ul>
{{#phases}}
<li class="{{#current}}is-active{{/current}}"><a>{{name}}</a></li>
{{/phases}}
</ul>
</div>
</div>
<footer class="card-footer">
<a class="card-footer-item is-primary">Pause</a>
<a class="card-footer-item">Stop</a>
</footer>
</div>`;
html.innerHTML = Mustache.render(template, data);
}
function manual_modal(data) {
let modal = document.getElementById("manual-modal");
let template = `<div class="modal {{^dismissed}}{{#required}}is-active{{/required}}{{/dismissed}}">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Manual Intervention Required</p>
<button class="delete" aria-label="close" onclick="dismiss_modal()"></button>
</header>
<section class="modal-card-body">
{{label}}
</section>
<footer class="modal-card-foot">
{{#options}}
<button class="button" onclick="respond_manual('{{value}}')">
{{tag}}
</button>
{{/options}}
</footer>
</div>
</div>`;
modal.innerHTML = Mustache.render(template, data);
}
let items = document.querySelectorAll('#items-list > li')
items.forEach(item => {
$(item).prop('draggable', true)
item.addEventListener('dragstart', dragStart)
item.addEventListener('drop', dropped)
item.addEventListener('dragenter', cancelDefault)
item.addEventListener('dragover', cancelDefault)
})
function dragStart (e) {
var index = $(e.target).index()
e.dataTransfer.setData('text/plain', index)
}
function dropped (e) {
cancelDefault(e)
// get new and old index
let oldIndex = e.dataTransfer.getData('text/plain')
let target = $(e.target)
let newIndex = target.index()
// remove dropped items at old place
let dropped = $(this).parent().children().eq(oldIndex).remove()
// insert the dropped items at new place
if (newIndex < oldIndex) {
target.before(dropped)
} else {
target.after(dropped)
}
}
function cancelDefault (e) {
e.preventDefault()
e.stopPropagation()
return false
}

7
dist/socket.io.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/socket.io.min.js.map vendored Normal file

File diff suppressed because one or more lines are too long

25
dist/style.css vendored Normal file
View File

@ -0,0 +1,25 @@
[draggable="true"] {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
ul.moveable {
list-style: none;
margin: 0px;
li {
list-style-image: none;
margin: 10px;
border: 1px solid #ccc;
padding: 4px;
border-radius: 4px;
color: #666;
cursor: move;
&:hover {
background-color: #eee;
}
}
}

BIN
fonts/FontAwesome.otf Normal file

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

35
persona.py Normal file
View File

@ -0,0 +1,35 @@
"Supposed to be a singleton"
class ManualIntervention():
def __init__(self):
self.intervention_happened = False
self.intervention_required = False
self.response = None
self.number_of_requests = 0
self.options = (('Ok', True),)
self.label = 'È richiesto un intervento manuale'
def set(self, response):
self.response = response
self.intervention_happened = True
return True
def get(self, label=None, options=None):
update = False
if not self.intervention_required:
update = True
self.intervention_required = True
self.label = label or self.label
self.options = options or self.options
self.number_of_requests += 1
return update, self.intervention_happened and self.response
def clear(self):
self.__init__()
return True
def getState(self):
return {
'label': self.label,
'required': self.intervention_required,
'options': tuple({'tag':o[0], 'value':o[1]} for o in self.options)
}

238
phasectrl.py Normal file
View File

@ -0,0 +1,238 @@
# Simple Finite State Machine used to control states
import json
from scm import eval, _, GLOBAL_ENV, stringify, load
from time import perf_counter
from chat import chat
from persona import ManualIntervention
from sensors import get_sensor_value
from control import fixed_duty_wrapper
from actuators import actuators
MY_GLOBAL_ENV = (
_('make-duty-controller', 3, fixed_duty_wrapper,
GLOBAL_ENV))
def safe_eval(data, env):
try:
return eval(data, env)
except Exception as e:
err = ' '.join(('evaluating:', data, ':', str(e)))
chat.send_sync(err)
return True
def load_recipe(json):
def load_phase(json):
def fallback(v):
return json.get(v, '#t')
return Phase(
json['name'], json['description'],
onexit=fallback('on_exit'),
onload=fallback('on_load'),
nextcond=fallback('exit_condition'))
return Recipe(
json['name'], json['variant'], json['description'],
json['controllers'],
tuple(load_phase(p) for p in json['phases']))
def load_recipes(file):
with open(file, "r") as f:
recipes = json.loads(f.read())
return tuple(load_recipe(recipe) for recipe in recipes)
def manual_intervention_wrapper(state):
def wrapper(args):
label = None
options = None
if len(args) > 0:
label = stringify(args.car, quote=False)
if len(args) > 1:
options = tuple(
(stringify(o.car, quote=False), o.cdr.car)
for o in args.cdr.car)
resp = state.manual.get(label, options)
if resp[0]:
print('REQUESTING CLIENT UPDATE')
state.onupdate()
return resp[1]
return wrapper
class State():
def __init__(self, recipe_file, onupdate=lambda: None):
self.recipes = load_recipes(recipe_file)
self.recipe = None
self.phase = None
self.manual = ManualIntervention()
self.baseenv = (
_('get-sensor', 1, lambda x: get_sensor_value(stringify(x.car, quote=False)),
_('notify', 1, lambda x: chat.send_sync(stringify(x.car, quote=False)),
_('hours', 1, lambda x: x.car * 60 * 60,
_('minutes', 1, lambda x: x.car * 60,
_('seconds', 1, lambda x: x.car,
_('manual-intervention', -1, manual_intervention_wrapper(self),
_('set-target', 2, self.set_target,
_('set-controller', 2, self.set_controller,
MY_GLOBAL_ENV)))))))))
self.env = self.baseenv
self.envdata = {}
self.onupdate = onupdate
def getState(self):
return {
'phase': self.phase
}
def now(self):
return perf_counter()
def postload(self):
if self.recipe is None:
return
self.phase = 0
self.env = (
_('recipe-name', 0, lambda x: self.recipe.name,
_('recipe-description', 0, lambda x: self.recipe.description,
_('time-in-this-phase', 0, lambda x: self.phase_time(),
self.baseenv))))
self.envdata['controllers'] = {}
for controller in self.recipe.controllers:
name, ctrl = safe_eval(controller, self.env)
self.envdata['controllers'][name] = ctrl
self.envdata['sensors'] = {}
self.loadphase(self.phase)
def loadByIdx(self, recipe):
self.recipe = self.recipes[recipe]
self.postload()
def loadByName(self, recipe):
r = tuple(i for (i, r) in enumerate(self.recipes) if r.name == recipe)
if len(r) > 0:
self.recipe = self.recipes[r[0]]
self.postload()
def current_phase(self):
if self.recipe is None:
return None
if self.phase >= len(self.recipe.phases) or self.phase < 0:
return None
return self.recipe.phases[self.phase]
def check(self):
return self.current_phase().satisfied_p(self.env)
def run_step(self):
# Update sensor values
for sensor in self.envdata.get('sensors', ()):
_, ctrl, target = self.envdata['sensors'][sensor]
self.envdata['sensors'][sensor] = (get_sensor_value(sensor), ctrl, target)
# Apply actuators
for controller in self.envdata.get('controllers', {}).keys():
ctrl = self.envdata['controllers'][controller]
response = ctrl.apply(self.envdata['sensors'][ctrl.input_label][0])
if self.check():
if self.next() is None:
return True
print(f'Waiting for {self.current_phase().nextcond}')
return False
def next(self):
self.current_phase().exit(self.env)
if self.phase < len(self.recipe.phases) - 1:
self.phase += 1
phase = self.recipe.phases[self.phase]
self.loadphase(self.phase)
self.manual.clear()
return phase
self.onupdate()
return None
def phase_time(self, arg=None):
return (self.now() - self.envdata['current-phase-loadtime'])
def loadphase(self, phaseidx):
phase = self.recipe.phases[phaseidx]
self.envdata['current-phase-loadtime'] = self.now()
resp = safe_eval(phase.onload, self.env)
self.onupdate()
return resp
def done(self):
self.recipe.done(self.env)
self.recipe = None
self.env = self.baseenv
self.envdata = {}
self.onupdate()
def set_target(self, args):
sensor, target = (stringify(args.car, quote=False), args.cdr.car)
value, controller, _ = self.envdata['sensors'][sensor]
self.envdata['sensors'][sensor] = (value, controller, target)
self.envdata['controllers'][controller].set_target(target)
print('setting sensor', sensor, controller, 'to target', target)
def set_controller(self, args):
controller, sensor = (stringify(args.car, quote=False),
stringify(args.cdr.car, quote=False))
self.envdata['controllers'][controller].set_input_label(sensor)
# Value/Controller/Target
self.envdata['sensors'][sensor] = (get_sensor_value(sensor), controller, None)
print('linking sensor', sensor, 'to controller', controller,
'(', self.envdata['controllers'][controller], ')')
class Recipe():
def __init__(self, name, variant='default', description='',
controllers=(),
phases=()):
self.name = name
self.controllers = controllers
self.description = description
self.phases = phases
def getState(self, currentphase=None):
def phasetodict(i, p):
return {
'name': p.name,
'text': p.text,
'nextcond': p.nextcond,
'current': currentphase == i,
# 'loaded_time': p.loaded_time,
'onload': p.onload,
'onexit': p.onexit,
# 'envdata': p.envdata
}
return {
'name': self.name,
'description': self.description,
'phases': [
phasetodict(i, p)
for (i, p) in enumerate(self.phases)
],
# 'phase': self.phase
}
def done(self, env):
safe_eval('(notify (concat "Recipe " (recipe-name) " completed at " (now)))',
env)
class Phase():
def __init__(self, name, text='', nextcond='#t', onload='', onexit=''):
self.name = name
self.text = text
self.nextcond = nextcond
self.onload = onload
self.onexit = onexit
# self.loaded_time = None
# self.env = env
# self.envdata = {}
def satisfied_p(self, env):
return safe_eval(self.nextcond, env)
def exit(self, env):
return safe_eval(self.onexit, env)
def revert(self, env):
# Undo state changes (attuators values)
pass

78
recipes.json Normal file
View File

@ -0,0 +1,78 @@
[
{
"name": "Yogurt",
"variant": "default",
"controllers": [
"(make-duty-controller \"heater\" 0.20 120)"
],
"description": "Pastorizza lo yogurt a 82 gradi, lascia raffreddare per fare l'inoculo, mantiene a 42 gradi fino a che è pronto.",
"phases": [
{
"name": "Preheat",
"description": "Preheat yogurt to 82°",
"on_load": "(begin (set-controller \"heater\" \"T_food\")\n(set-target \"T_food\" 82.0))",
"exit_condition": "(and (>= (get-sensor \"T_food\") 81.5)\n(> (time-in-this-phase) (seconds 1)))"
},
{
"name": "Cool",
"description": "Cool to 46°",
"exit_condition": "(and (< (get-sensor \"T_food\") 46.0)\n(> (get-sensor \"T_food\") 42.0))"
},
{
"name": "Hold",
"description": "Keep at 42° for 3h",
"exit_condition": "(> (time-in-this-phase) (seconds 5))",
"on_exit": "(notify \"You can remove the yogurt from the heater\")"
},
{
"name": "Inoculate",
"description": "Add 50ml Yogurt",
"on_load": "(notify \"Inoculate 50g of yogurt NOW!\")",
"exit_condition": "(manual-intervention \"Inocula 50g di yogurt\" '((\"Fatto\" #t)))",
"on_exit": "(notify (concat \"Inoculated at \" (now)))"
},
{
"name": "Hold",
"description": "Keep at 42° for 3h",
"exit_condition": "(> (time-in-this-phase) (seconds 3))",
"on_exit": "(notify \"You can remove the yogurt from the heater\")"
}
]
},
{
"name": "Nattō",
"variant": "default",
"controllers": [
"(make-duty-controller \"heater\" 0.20 120)"
],
"description": "FIXME",
"phases": []
},
{
"name": "Tempeh",
"variant": "default",
"controllers": [
"(make-duty-controller \"heater\" 0.20 120)"
],
"description": "FIXME",
"phases": []
},
{
"name": "Koji Rice",
"variant": "default",
"controllers": [
"(make-duty-controller \"heater\" 0.20 120)"
],
"description": "FIXME",
"phases": []
},
{
"name": "Gorgonzola",
"variant": "default",
"controllers": [
"(make-duty-controller \"heater\" 0.20 120)"
],
"description": "FIXME",
"phases": []
}
]

517
scm.py Normal file
View File

@ -0,0 +1,517 @@
#!/usr/bin/env python
"""
A Little Scheme in Python 2.7/3.8, v3.2 H31.01.13/R02.04.09 by SUZUKI Hisao
"""
from __future__ import print_function
from sys import argv, exit
try:
from sys import intern # for Python 3
raw_input = input # for Python 3
long = int # for Python 3
except ImportError:
pass
class List (object):
"Empty list"
__slots__ = ()
def __iter__(self):
return iter(())
def __len__(self):
n = 0
for e in self:
n += 1
return n
NIL = List()
class Cell (List):
"Cons cell"
__slots__ = ('car', 'cdr')
def __init__(self, car, cdr):
self.car, self.cdr = car, cdr
def __iter__(self):
"Yield car, cadr, caddr and so on."
j = self
while isinstance(j, Cell):
yield j.car
j = j.cdr
if j is not NIL:
raise ImproperListError(j)
class ImproperListError (Exception):
pass
QUOTE = intern('quote') # Use an interned string as a symbol.
IF = intern('if')
BEGIN = intern('begin')
LAMBDA = intern('lambda')
DEFINE = intern('define')
DEFINE_RECORD_TYPE = intern('define-record-type')
SETQ = intern('set!')
APPLY = intern('apply')
CALLCC = intern('call/cc')
NOCONT = () # NOCONT means there is no continuation.
# Continuation operators
THEN = intern('then')
APPLY_FUN = intern('aplly-fun')
EVAL_ARG = intern('eval-arg')
CONS_ARGS = intern('cons-args')
RESTORE_ENV = intern('restore-env')
class ApplyClass:
def __str__(self):
return '#<apply>'
class CallCcClass:
def __str__(self):
return '#<call/cc>'
APPLY_OBJ = ApplyClass()
CALLCC_OBJ = CallCcClass()
class SchemeString:
"String in Scheme"
def __init__(self, string):
self.string = string
def __repr__(self):
return '"' + self.string + '"'
class Environment (object):
"Linked list of bindings mapping symbols to values"
__slots__ = ('sym', 'val', 'next')
def __init__(self, sym, val, next):
"(env.sym is None) means the env is the frame top. "
self.sym, self.val, self.next = sym, val, next
def __iter__(self):
"Yield each binding in the linked list."
env = self
while env is not None:
yield env
env = env.next
def look_for(self, symbol):
"Search the bindings for a symbol."
for env in self:
if env.sym is symbol:
return env
raise NameError(symbol)
def prepend_defs(self, symbols, data):
"Build an environment prepending the bindings of symbols and data."
if symbols is NIL:
if data is not NIL:
raise TypeError('surplus arg: ' + stringify(data))
return self
else:
if data is NIL:
raise TypeError('surplus param: ' + stringify(symbols))
return Environment(symbols.car, data.car,
self.prepend_defs(symbols.cdr, data.cdr))
class Closure (object):
"Lambda expression with its environment"
__slots__ = ('params', 'body', 'env')
def __init__(self, params, body, env):
self.params, self.body, self.env = params, body, env
class Intrinsic (object):
"Built-in function"
__slots__ = ('name', 'arity', 'fun')
def __init__(self, name, arity, fun):
self.name, self.arity, self.fun = name, arity, fun
def __repr__(self):
return '#<%s:%d>' % (self.name, self.arity)
def stringify(exp, quote=True):
"Convert an expression to a string."
if exp is True:
return '#t'
elif exp is False:
return '#f'
elif isinstance(exp, List):
ss = []
try:
for element in exp:
ss.append(stringify(element, quote))
except ImproperListError as ex:
ss.append('.')
ss.append(stringify(ex.args[0], quote))
return '(' + ' '.join(ss) + ')'
elif isinstance(exp, Environment):
ss = []
for env in exp:
if env is GLOBAL_ENV:
ss.append('GlobalEnv')
break
elif env.sym is None: # marker of the frame top
ss.append('|')
else:
ss.append(env.sym)
return '#<' + ' '.join(ss) + '>'
elif isinstance(exp, Closure):
p, b, e = [stringify(x) for x in (exp.params, exp.body, exp.env)]
return '#<' + p + ':' + b + ':' + e + '>'
elif isinstance(exp, tuple) and len(exp) == 3:
p, v, k = [stringify(x) for x in exp]
return '#<' + p + ':' + v + ':\n ' + k + '>'
elif isinstance(exp, SchemeString) and not quote:
return exp.string
else:
return str(exp)
def _globals(x):
"Return a list of keys of the global environment."
j, env = NIL, GLOBAL_ENV.next # Take next to skip the marker.
for e in env:
j = Cell(e.sym, j)
return j
def _error(x):
"Based on SRFI-23"
raise ErrorException("Error: %s: %s" % (stringify(x.car, False),
stringify(x.cdr.car)))
class ErrorException (Exception):
pass
_ = lambda n, a, f, next: Environment(intern(n), Intrinsic(n, a, f), next)
GLOBAL_ENV = (
_('+', 2, lambda x: x.car + x.cdr.car,
_('-', 2, lambda x: x.car - x.cdr.car,
_('*', 2, lambda x: x.car * x.cdr.car,
_('/', 2, lambda x: x.car / x.cdr.car,
_('>', 2, lambda x: x.car > x.cdr.car,
_('>=', 2, lambda x: x.car >= x.cdr.car,
_('<', 2, lambda x: x.car < x.cdr.car,
_('<=', 2, lambda x: x.car <= x.cdr.car,
_('=', 2, lambda x: x.car == x.cdr.car,
_('and', 2, lambda x: x.car and x.cdr.car,
_('number?', 1, lambda x: isinstance(x.car, (int, float, long)),
_('error', 2, _error,
_('globals', 0, _globals,
None))))))))))))))
# My custom helpers
from datetime import datetime
GLOBAL_ENV = (
_('now', 0, lambda x: datetime.now(),
_('concat', -1, lambda x: ''.join((stringify(x, False) for x in x)),
_('exit', 0, lambda x: exit(),
GLOBAL_ENV))))
# records = {}
# def make_record_type(name, params):
# # {n: None for n in x.cdr}
# # exp, k = kdr.cdr.car, (SETQ, env.look_for(kdr.car), k)
# # def first(cons, res=()):
# # print(cons, 'res', res)
# # if cons is NIL:
# # return res
# # return first(cons.cdr, (*res, cons.car))
# # print('params:', first(params))
# # print('x:', params.car.car)
# records[name] = tuple(x for x in params.car)
# print(records)
# return params
# Records
GLOBAL_ENV = (
_('record?', 1, lambda x: x.car.string in records.keys(),
GLOBAL_ENV))
GLOBAL_ENV = (
_('display', 1, lambda x: print(stringify(x.car, False), end=''),
_('newline', 0, lambda x: print(),
_('read', 0, lambda x: read_expression('', ''),
_('eof-object?', 1, lambda x: isinstance(x.car, EOFError),
_('symbol?', 1, lambda x: isinstance(x.car, str),
Environment(CALLCC, CALLCC_OBJ,
Environment(APPLY, APPLY_OBJ,
GLOBAL_ENV))))))))
GLOBAL_ENV = Environment(
None, None, # marker of the frame top
_('car', 1, lambda x: x.car.car,
_('cdr', 1, lambda x: x.car.cdr,
_('cons', 2, lambda x: Cell(x.car, x.cdr.car),
_('eq?', 2, lambda x: x.car is x.cdr.car,
_('pair?', 1, lambda x: isinstance(x.car, Cell),
_('null?', 1, lambda x: x.car is NIL,
_('not', 1, lambda x: x.car is False,
_('list', -1, lambda x: x,
GLOBAL_ENV)))))))))
def evaluate(exp, env=GLOBAL_ENV):
"Evaluate an expression in an environment."
k = NOCONT
try:
while True:
while True:
if isinstance(exp, Cell):
kar, kdr = exp.car, exp.cdr
if kar is QUOTE: # (quote e)
exp = kdr.car
break
elif kar is IF: # (if e1 e2 e3) or (if e1 e2)
exp, k = kdr.car, (THEN, kdr.cdr, k)
elif kar is BEGIN: # (begin e...)
exp = kdr.car
if kdr.cdr is not NIL:
k = (BEGIN, kdr.cdr, k)
elif kar is LAMBDA: # (lambda (v...) e...)
exp = Closure(kdr.car, kdr.cdr, env)
break
elif kar is DEFINE: # (define v e)
v = kdr.car
assert isinstance(v, str), v
exp, k = kdr.cdr.car, (DEFINE, v, k)
elif kar is DEFINE_RECORD_TYPE: # x = v
print(kar, kdr.car, kdr.cdr.car.car)
exp, k = kdr.cdr.car, (DEFINE_RECORD_TYPE, kdr.car, kdr.cdr.car)
exp = None
elif kar is SETQ: # (set! v e)
exp, k = kdr.cdr.car, (SETQ, env.look_for(kdr.car), k)
else:
exp, k = kar, (APPLY, kdr, k)
elif isinstance(exp, str):
exp = env.look_for(exp).val
break
else: # as a number, #t, #f etc.
break
while True:
if k is NOCONT:
return exp
op, x, k = k
if op is THEN: # x = (e2 e3)
if exp is False:
if x.cdr is NIL:
exp = None
else:
exp = x.cdr.car # e3
break
else:
exp = x.car # e2
break
elif op is BEGIN: # x = (e...)
if x.cdr is not NIL: # unless tail call...
k = (BEGIN, x.cdr, k)
exp = x.car
break
elif op is DEFINE: # x = v
assert env.sym is None # Check for the marker.
env.next = Environment(x, exp, env.next)
exp = None
elif op is DEFINE_RECORD_TYPE: # x = v
assert env.sym is None # Check for the marker.
# for x, exp in ():
# env.next = Environment(x, exp, env.next)
print('OP, x, k', op, x, k, exp)
# DEFINE:
# 1. x?
# 2. x-a, x-b, x-c
exit()
env.next = Environment(x, exp, env.next)
exp = None
elif op is SETQ: # x = Environment(v, e, next)
x.val = exp
exp = None
elif op is APPLY: # x = args; exp = fun
if x is NIL:
exp, k, env = apply_function(exp, NIL, k, env)
else:
k = (APPLY_FUN, exp, k)
while x.cdr is not NIL:
k = (EVAL_ARG, x.car, k)
x = x.cdr
exp = x.car
k = (CONS_ARGS, NIL, k)
break
elif op is CONS_ARGS: # x = evaluated args
args = Cell(exp, x)
op, exp, k = k
if op is EVAL_ARG: # exp = the next arg
k = (CONS_ARGS, args, k)
break
elif op is APPLY_FUN: # exp = evaluated fun
exp, k, env = apply_function(exp, args, k, env)
else:
raise RuntimeError('unexpected op: %s: %s' %
(stringify(op), stringify(exp)))
elif op is RESTORE_ENV: # x = env
env = x
else:
raise RuntimeError('bad op: %s: %s' %
(stringify(op), stringify(x)))
except ErrorException:
raise
except Exception as ex:
msg = type(ex).__name__ + ': ' + str(ex)
if k is not NOCONT:
msg += '\n ' + stringify(k)
raise Exception(msg)
def apply_function(fun, arg, k, env):
"""Apply a function to arguments with a continuation.
It returns (result, continuation, environment).
"""
while True:
if fun is CALLCC_OBJ:
k = _push_RESTORE_ENV(k, env)
fun, arg = arg.car, Cell(k, NIL)
elif fun is APPLY_OBJ:
fun, arg = arg.car, arg.cdr.car
else:
break
if isinstance(fun, Intrinsic):
if fun.arity >= 0:
if len(arg) != fun.arity:
raise TypeError('arity not matched: ' + str(fun) + ' and '
+ stringify(arg))
return fun.fun(arg), k, env
elif isinstance(fun, Closure):
k = _push_RESTORE_ENV(k, env)
k = (BEGIN, fun.body, k)
env = Environment(None, None, # marker of the frame top
fun.env.prepend_defs(fun.params, arg))
return None, k, env
elif isinstance(fun, tuple): # as a continuation
return arg.car, fun, env
else:
raise TypeError('not a function: ' + stringify(fun) + ' with '
+ stringify(arg))
def _push_RESTORE_ENV(k, env):
if k is NOCONT or k[0] is not RESTORE_ENV: # unless tail call...
k = (RESTORE_ENV, env, k)
return k
def split_string_into_tokens(source_string):
"split_string_into_tokens('(a 1)') => ['(', 'a', '1', ')']"
result = []
for line in source_string.split('\n'):
ss, x = [], []
for i, e in enumerate(line.split('"')):
if i % 2 == 0:
x.append(e)
else:
ss.append('"' + e) # Append a string literal.
x.append('#s')
s = ' '.join(x).split(';')[0] # Ignore ;-commment.
s = s.replace("'", " ' ").replace(')', ' ) ').replace('(', ' ( ')
x = s.split()
result.extend([(ss.pop(0) if e == '#s' else e) for e in x])
assert not ss
return result
def read_from_tokens(tokens):
"""Read an expression from a list of token strings.
The list will be left with the rest of token strings, if any.
"""
token = tokens.pop(0)
if token == '(':
y = z = Cell(NIL, NIL)
while tokens[0] != ')':
if tokens[0] == '.':
tokens.pop(0)
y.cdr = read_from_tokens(tokens)
if tokens[0] != ')':
raise SyntaxError(') is expected')
break
e = read_from_tokens(tokens)
y.cdr = Cell(e, NIL)
y = y.cdr
tokens.pop(0)
return z.cdr
elif token == ')':
raise SyntaxError('unexpected )')
elif token == "'":
e = read_from_tokens(tokens)
return Cell(QUOTE, Cell(e, NIL)) # 'e => (quote e)
elif token == '#f':
return False
elif token == '#t':
return True
elif token[0] == '"':
return SchemeString(token[1:])
else:
try:
return int(token)
except ValueError:
try:
return float(token)
except ValueError:
return intern(token) # as a symbol
def eval(code, env=GLOBAL_ENV):
tokens = split_string_into_tokens(code)
resp = None
while tokens:
exp = read_from_tokens(tokens)
resp = evaluate(exp, env)
return resp
def load(file_name, env=GLOBAL_ENV):
"Load a source code from a file."
with open(file_name) as rf:
source_string = rf.read()
return eval(source_string, env)
TOKENS = []
def read_expression(prompt1='> ', prompt2='| '):
"Read an expression."
while True:
old = TOKENS[:]
try:
return read_from_tokens(TOKENS)
except IndexError: # tokens have run out unexpectedly.
try:
source_string = raw_input(prompt2 if old else prompt1)
except EOFError as ex:
return ex
TOKENS[:] = old
TOKENS.extend(split_string_into_tokens(source_string))
except SyntaxError:
del TOKENS[:] # Discard the erroneous tokens.
raise
def read_eval_print_loop():
"Repeat read-eval-print until End-of-File."
while True:
try:
exp = read_expression()
if isinstance(exp, EOFError):
print('Goodbye')
return
result = evaluate(exp)
if result is not None:
print(stringify(result, True))
except Exception as ex:
print(ex)
# import os
# print(load(os.path.join(os.path.dirname(__file__), 'init.scm')))
if __name__ == '__main__':
if argv[1:2]:
load(argv[1])
if argv[2:3] != ['-']:
exit(0)
read_eval_print_loop()

88
sensors.py Normal file
View File

@ -0,0 +1,88 @@
from time import perf_counter
from datetime import datetime, timedelta
from collections import deque
import re
# TODO client: add a way to disable live plotting? server: stop
# sending data periodically to everybody!
from random import random
from time import sleep
class Sensor():
def __init__(self, autocorr=0.7):
self.prevval = random() * 100
self.auto = autocorr
self.measure = None
def read(self):
sleep(0.5)
return (perf_counter(),
int((random() * (1-self.auto) + self.prevval * self.auto) * 100) / 100)
class Temperature1W(Sensor):
def __init__(self, address):
super().__init__()
self.measure = 'Temperature'
self.address = address
def path(self):
return f'/sys/bus/w1/devices/{self.address}/w1_slave'
def read(self):
with open(self.path(), "r") as f:
content = f.readlines()
if len(content) < 2:
return None
if content[0].strip()[-3:] != "YES":
print("INVALID CHECKSUM")
return None
return int(re.search("t=([0-9]+)", content[1]).group(1)) / 1000.0
class Sensors():
def __init__(self, history=2621440):
self.starttime = (datetime.now(), perf_counter())
self.scan()
self.values = {}
self.history = deque([], maxlen=history)
def perf_datetime(self, offset):
time = offset - self.starttime[1]
return self.starttime[0] + timedelta(seconds=time)
"Scan for new sensors"
def scan(self):
# FIXME: should scan, apply stored data and return this
self.available_sensors = {
'T_ext': Temperature1W('28-06214252b671'),
'T_food': Temperature1W('28-062142531e5a'),
}
def list(self):
return {
k: self.available_sensors[k].measure
for k in self.available_sensors.keys()
}
def value_tuple(self):
return tuple((k, *self.values[k]) for k in self.values.keys())
def read(self):
for sensor in self.available_sensors.keys():
time, value = self.available_sensors[sensor].read()
self.values[sensor] = (str(self.perf_datetime(time)), value)
self.history.append(self.value_tuple())
def get_sensor_value(self, sensor_name):
return self.values.get(sensor_name, None)
def get(self):
return self.value_tuple()
def get_history(self):
return tuple(self.history)
sensors = Sensors()
def get_sensor_value(sensor_name):
return sensors.get_sensor_value(sensor_name)[1]

56
templates/base.html Normal file
View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %} {% endblock %}</title>
<script src="/dist/socket.io.min.js" crossorigin="anonymous"></script>
<script src="/dist/server.js" crossorigin="anonymous"></script>
<script src="/dist/mustache.js" crossorigin="anonymous"></script>
<script src="/dist/plotly-2.16.1.min.js" crossorigin="anonymous"></script>
<script type="text/javascript" charset="utf-8">
var socket = null;
function setup() {
socket = io();
socket.on('connect', function() {
const engine = socket.io.engine;
socket.emit('new client');
engine.on('packet', ({ type, data }) => {
// called for each packet received
/* console.log('received packet: '+ type + data); */
});
// receive a message from the client
socket.on("state", (data) => {
applyState(data);
});
socket.on("sensors", (data) => {
add_plot_data(data);
render_sensors(data);
});
socket.on("sensor history", (...data) => {
enable_draw = false;
data.forEach(add_plot_data);
enable_draw = true;
render_plot();
});
});
}
window.onload = setup;
</script>
<link href="/dist/style.css" rel="stylesheet">
<link href="/dist/font-awesome.min.css" rel="stylesheet">
<link href="/dist/bulma.min.css" rel="stylesheet">
</head>
<body>
{% raw %}
{% endraw %}
<div id="manual-modal"></div>
<div class="columns">
<div class="column is-one-fifth" id="sidebar">
</div>
<div class="column">
<div class="block">
{% block content %} {% endblock %}
</div>
</div>
</div>
</body>
</html>

43
templates/index.html Normal file
View File

@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}Hakkoso{% endblock %}</h1>
{% endblock %}
{% block content %}
<div class="columns">
<div class="column">
<br/>
<div class="card">
<header class="card-header">
<p class="card-header-title">
Sensors
</p>
</header>
<div class="card-content">
<div id="sensor-list"></div>
</div>
</div>
<br/>
<div class="card">
<header class="card-header">
<p class="card-header-title">
Sensors Data
</p>
</header>
<div class="card-content">
<div class="content">
<div id="data-plot"></div>
<br/>
</div>
</div>
</div>
</div>
<div class="column">
<br/>
<div id="current-recipe"></div>
<br/>
<div id="load-recipe"></div>
<br/>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,91 @@
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}Hakkoso{% endblock %}</h1>
{% endblock %}
{% block content %}
<!-- https://codepen.io/PJCHENder/pen/PKBVRO/ -->
<ul id="items-list" class="moveable">
<li>One</li>
<li>Two</li>
<li>Three</li>
<li>Four</li>
</ul>
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label">Recipe Name</label>
<div class="control">
<input class="input" type="text" placeholder="Text input">
</div>
</div>
<div class="field">
<label class="label">Description</label>
<div class="control">
<textarea class="textarea" placeholder="Recipe Description"></textarea>
</div>
</div>
Phase list:
<div class="tile is-ancestor">
<div class="tile is-vertical">
<div class="tile">
<div class="tile is-parent is-vertical">
<article class="tile is-child notification is-primary">
<p class="title"></p>
<p class="subtitle">Top tile</p>
</article>
</div>
</div>
</div>
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label">Phase Name</label>
<div class="control">
<input class="input" type="text" placeholder="Phase Name">
</div>
</div>
<div class="field">
<label class="label">Phase Description</label>
<div class="control">
<textarea class="textarea" placeholder="Phase Description"></textarea>
</div>
</div>
<div class="field">
<label class="label">On Load</label>
<div class="control">
<textarea class="textarea" placeholder="#t"></textarea>
</div>
</div>
<div class="field">
<label class="label">Next Condition</label>
<div class="control">
<textarea class="textarea" placeholder="(> (time-in-this-phase) (minutes 1))"></textarea>
</div>
</div>
<div class="field">
<label class="label">On Exit</label>
<div class="control">
<textarea class="textarea" placeholder="#t"></textarea>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Submit</button>
</div>
<div class="control">
<button class="button is-link is-light">Cancel</button>
</div>
<div class="control">
<button class="button is-danger">Delete</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

28
templates/recipe.html Normal file
View File

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}Recipe{% endblock %}</h1>
{% endblock %}
{% block content %}
<h2>{{recipe.name}} Recipe</h2>
<h3>Description</h3>
<p>
{{recipe.description}}
</p>
<h3>Phases</h3>
{% for p in recipe.sm.phases %}
<h4>{{p.name}}</h4>
{{p.text}}
{% endfor %}
<h3>Required Sensors</h3>
Boh
</br>
</br>
Current load (kg)
<input value="Automatic"></input>
</br>
</br>
<button>Start!</button>
{% endblock %}