├── .gitignore ├── README.md ├── TODO.txt ├── adquisitor.py ├── arakur_ww ├── arakur_ww.py ├── config.py ├── constants.py ├── daemon.py ├── forms.py ├── models.py ├── plc_interface.py ├── static │ ├── .gitkeep │ ├── EventSource.js │ ├── background.png │ ├── base.css │ ├── bootstrap.min.css │ ├── event_manager.js │ ├── flask-logo.png │ ├── gpl-logo.png │ ├── jquery.flot.js │ ├── jquery.min.js │ ├── logo-python.png │ ├── logo.png │ └── update.js ├── templates │ ├── admin.html │ ├── base.html │ ├── graficos.html │ ├── index.html │ ├── login.html │ ├── parametros.html │ └── programa.html └── utils.py ├── doc └── configurar_red.md ├── mock_planta └── server.py ├── requirements.txt ├── upstart_jobs ├── sbr_daemon.conf └── sbr_web.conf └── web.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.pyc 3 | env 4 | redis 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monitoring system for wasted water plant 2 | 3 | ## Architecture 4 | 5 | The plant is operated by a Schneider Twido PLC, that have a MODBUS TCP/IP interface to read and set parameters. 6 | 7 | The original design of SCADA is based on QT. But to make use of new browser technologies like event-stream, flask and many others, is now completly redesigned. 8 | 9 | Main interface is Twitter Bootstrap based flask aplication. 10 | Data adquisition is made with a custom thread based python daemon. 11 | 12 | Modbus comunication is provided by twisted based *pymodbus* library. 13 | All adquired data is stored in a MySQL Database, the selected ORM is peewee, that is lightweight and simple. 14 | 15 | ## Main Data entities 16 | 17 | * event_log 18 | * alarm_log 19 | * level_log 20 | * oxigen_log 21 | * cloudiness_log 22 | 23 | ## Data Adquisition Strategy 24 | To aquire data from PLC, we made a MODBUS/TCP client conection, and poll for data ever half second. 25 | In this step all data is pushed in json format througth a event-stream connection to brower, updating graphs and aplication info. 26 | 27 | ## How to test application 28 | Yo need to have following packages installed: 29 | 30 | * Python 2.6 or 2.7 (preferred) 31 | * Redis: http://redis.io 32 | * Python distribute #FIXME ¿Is virtualenv there?? 33 | 34 | ### How to get this thing working 35 | 36 | * Create a new virtualenv for application: virtualenv env 37 | * Install requirements: pip install -r requirements.txt 38 | * Start redis (if you not started it yet) 39 | * Configure redis connection on arakur_ww/config.py 40 | * Start PLC mock: python mock_planta/server.py 41 | * Start adquisition daemon python arakur_ww/daemon.py 42 | * Start Web server python arakur_ww/arakur_ww.py 43 | * Enter to http://localhost:5000 using a modern browser 44 | 45 | 46 | ## Production deploy 47 | 48 | Target linux is Ubuntu 12.10 49 | 50 | * Install dependencies 51 | * Create a new user "hmi" 52 | * Clone repository inside home directory of this user 53 | * Add execution rights to web.py and adquisitor.py (chmod +x) 54 | * Copy upstart jobs from upstart_jobs to /etc/init 55 | * Start jobs, manually: start sbr_daemon, start sbr_web 56 | 57 | 58 | ## FAQ 59 | 60 | ### Where is the PLC program? 61 | Sorry, I only develop the HMI, but, if you write to the apropiate Modbus registers, (defined in arakur_ww/constants.py), everything have to work well 62 | 63 | 64 | ### Why you have twisted, and serve the application with Flask? 65 | 66 | Is a design choice, Because flask is really confortable to work with, and really, web app and adquisition daemon are different services. 67 | 68 | ### Why this FAQ? 69 | 70 | Because... you are reading It. 71 | 72 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | TODO 2 | ==== 3 | 4 | [_] Add Logging to daemon 5 | [_] Add button to stop cycle 6 | [x] Add button to start cycle 7 | [x] Add bars for cycle times 8 | [x] Display program number on dashboard 9 | [_] Require admin for edit programms and cycle management 10 | [x] Cancel button on program edit dialog (Show nav bar, instead cancel button) 11 | [_] View to edit level, oxigen and cloudiness limits 12 | [_] Color managment of progress bars 13 | [_] Confirm Dialog on stop and start buttons 14 | [_] Create user roles 15 | [_] Timestamp state (alarms, events) 16 | [_] Put alarms and events in stak order 17 | [_] Automate aplication start (cron job on power on) 18 | [_] Timestamp data log 19 | [_] Log instant data to mysql db 20 | [_] Write javascript code to get graph data 21 | [_] Graph Pagination ? Tho calendar fields whit max aperture of two years? Button for "today" "this week" "this month" 22 | [_] CSV data exporter for selected month. 23 | -------------------------------------------------------------------------------- /adquisitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #! -*- coding: utf8 -*- 3 | #Activación del virtualenv 4 | 5 | import sys 6 | repo_path = '/home/hmi/ArakurWW/' 7 | sys.path.insert(0, repo_path + 'arakur_ww') 8 | 9 | activate_this = '/home/hmi/ArakurWW/env/bin/activate_this.py' 10 | execfile(activate_this, dict(__file__=activate_this)) 11 | 12 | import daemon 13 | #Starting process 14 | daemon.run() 15 | -------------------------------------------------------------------------------- /arakur_ww/arakur_ww.py: -------------------------------------------------------------------------------- 1 | #! -*- coding: utf8 -*- 2 | import redis 3 | import utils 4 | import config 5 | from utils import RemoteCommand 6 | 7 | from flask import Flask, url_for, render_template, Response, json, jsonify, redirect, request, flash 8 | from flask.ext.bootstrap import Bootstrap 9 | from flask.ext.login import LoginManager, login_required, login_user, logout_user 10 | from forms import LoginForm, ProgramForm, ParametersForm 11 | from models import User 12 | 13 | SECRET_KEY = 'development key' 14 | BOOTSTRAP_JQUERY_VERSION = None 15 | 16 | app = Flask(__name__) 17 | app.config.from_object(__name__) 18 | 19 | Bootstrap(app) 20 | 21 | broker = redis.StrictRedis(**config.REDIS) 22 | 23 | login_manager = LoginManager() 24 | login_manager.setup_app(app) 25 | login_manager.login_view = '/login' 26 | login_manager.login_message = u'Debe logearse para poder acceder' 27 | 28 | @login_manager.user_loader 29 | def load_user(userid): 30 | #TODO arreglar esto para que loguee en serio 31 | if userid == 'admin': 32 | return User() 33 | return None 34 | 35 | @app.route('/logout') 36 | @login_required 37 | def logout(): 38 | logout_user() 39 | return redirect(url_for('login')) 40 | 41 | 42 | @app.route('/login', methods=["GET", "POST"]) 43 | def login(): 44 | form = LoginForm() 45 | if form.validate_on_submit(): 46 | #FIXME arreglar para tener multiples usuarios 47 | login_user(User()) 48 | return redirect(request.args.get("next") or url_for('index')) 49 | 50 | return render_template("login.html", form=form) 51 | 52 | 53 | def enviar_comando(function_name, *args, **kwargs): 54 | """Envia un comando remoto al servidor del plc""" 55 | stream = broker.pubsub() 56 | stream.subscribe('command-returns') 57 | 58 | command = RemoteCommand(function_name, *args, **kwargs) 59 | receptor = broker.publish('commands', command.serialize()) 60 | 61 | if receptor: 62 | for message in stream.listen(): 63 | if message['type'] == 'message': 64 | data = json.loads(message['data']) 65 | print data 66 | if data['id'] == command.id: 67 | return data['success'] 68 | 69 | return False 70 | 71 | 72 | def event_stream(): 73 | stream = broker.pubsub() 74 | stream.subscribe('plc_state') 75 | for data in stream.listen(): 76 | if data['type'] == 'message': 77 | yield 'data: %s\n\n' % data['data'] 78 | 79 | @app.route('/') 80 | @login_required 81 | def index(): 82 | return render_template('index.html') 83 | 84 | @app.route('/graficos') 85 | @login_required 86 | def graficos(): 87 | return render_template('graficos.html') 88 | 89 | @app.route('/stream') 90 | @login_required 91 | def stream(): 92 | return Response(event_stream(), mimetype="text/event-stream") 93 | 94 | 95 | @app.route('/admin') 96 | def admin(): 97 | 98 | programas = {} 99 | for n in utils.programas_validos(): 100 | key = "programa_%d" % n 101 | programas[n] = broker.hgetall(key) 102 | params = broker.hgetall('params') 103 | 104 | return render_template('admin.html', programas=programas, params=params) 105 | 106 | @app.route('/alarma/stop') 107 | @login_required 108 | def detener_alarma(): 109 | enviar_comando('detener_alarma') 110 | return 'Enviado!!' 111 | 112 | @app.route('/sbr/stop') 113 | @login_required 114 | def detener_sbr(): 115 | ret = enviar_comando('detener_sbr') 116 | if ret: 117 | flash(u'SBR Detenido!', 'success') 118 | else: 119 | flash(u'Ocurrió un error al detener el SBR', 'error') 120 | 121 | return redirect(url_for('admin')) 122 | 123 | @app.route('/acumulado/reset') 124 | @login_required 125 | def reset_acumulado(): 126 | enviar_comando('reset_acumulado') 127 | return 'Enviado!!' 128 | 129 | @app.route('/iniciar/') 130 | @login_required 131 | def iniciar_programa(programa): 132 | if utils.programa_valido(programa): 133 | ret = enviar_comando('iniciar_programa', programa) 134 | if ret: 135 | flash(u'Programa Nº%d Iniciado!' % programa, 'success') 136 | else: 137 | flash(u'Ocurrió un error al iniciar el programa nº%d' % programa, 'error') 138 | 139 | return redirect(url_for('admin')) 140 | 141 | @app.route('/programa/', methods=['GET', 'POST']) 142 | @login_required 143 | def actualizar_programa(programa): 144 | 145 | if not utils.programa_valido(programa): 146 | flash(u"El numero de programa %d es invalido" % programa, 'error') 147 | return redirect(url_for('admin')) 148 | 149 | form = ProgramForm() 150 | if form.validate_on_submit(): 151 | ret = enviar_comando('actualizar_programa', 152 | programa, 153 | form.carga_aireada.data, 154 | form.aireacion.data, 155 | form.sedimentacion.data, 156 | form.descarga.data, 157 | ) 158 | if ret: 159 | flash(u'Programa Nº%d Actualizado!' % programa, 'success') 160 | else: 161 | flash(u'Ocurrió un error al actualizar el programa nº%d' % programa, 'error') 162 | return redirect(url_for('admin')) 163 | else: 164 | key = "programa_%d" % programa 165 | program_values = broker.hgetall(key) 166 | for key, value in program_values.iteritems(): 167 | field = getattr(form, key) 168 | field.data = value 169 | 170 | return render_template('programa.html', form=form, programa=programa) 171 | 172 | @app.route('/parametros', methods=['GET', 'POST']) 173 | @login_required 174 | def actualizar_parametros(): 175 | 176 | form = ParametersForm() 177 | if form.validate_on_submit(): 178 | ret = enviar_comando('actualizar_parametros', 179 | int(form.oxigen_min.data * 100), 180 | int(form.oxigen_max.data * 100), 181 | int(form.cloudiness_max.data), 182 | ); 183 | if ret: 184 | flash(u'Parametros actualizados!', 'success') 185 | else: 186 | flash(u'Ocurrió un error al actualizar los parametros', 'error') 187 | return redirect(url_for('admin')) 188 | else: 189 | params = broker.hgetall('params') 190 | for key, value in params.iteritems(): 191 | field = getattr(form, key, None) 192 | if field is not None: 193 | print field.type 194 | if field.type == "DecimalField": 195 | field.data = float(value) 196 | else: 197 | field.data = value 198 | 199 | return render_template('parametros.html', form=form) 200 | 201 | 202 | #Debug methods, para poder escribir registro y marcas aleatorias del plc 203 | 204 | if __name__ == '__main__': 205 | 206 | @app.route('/registro///') 207 | def actualizar_registro(direccion, valor): 208 | enviar_comando('_escribir_registro', direccion, valor) 209 | return "enviado!" 210 | 211 | @app.route('/marca///') 212 | def actualizar_marca(direccion, valor): 213 | valor = valor > 0 214 | enviar_comando('_escribir_marca', direccion, valor) 215 | return "enviado!" 216 | 217 | app.run(debug=True, threaded=True, host='0.0.0.0') 218 | -------------------------------------------------------------------------------- /arakur_ww/config.py: -------------------------------------------------------------------------------- 1 | PLC = { 2 | #'host' : 'localhost', 3 | 'host' : '192.168.1.33', 4 | 'port' : 502, 5 | } 6 | REDIS ={ 7 | 'host' : 'localhost', 8 | 'port' : 6379, 9 | 'db' : 0, 10 | } 11 | -------------------------------------------------------------------------------- /arakur_ww/constants.py: -------------------------------------------------------------------------------- 1 | #! -*-coding: utf8-*- 2 | """Constantes que se utilizan en la interfaz con el plc""" 3 | 4 | ERROR_CODE = 0x80 5 | M_ALARM_OFF = 23 6 | M_STOP_SBR = 30 7 | M_START_SBR = 31 8 | M_RESET_VOL = 32 9 | MARK_COUNT = 33 10 | REGISTER_COUNT = 33 11 | 12 | ACTUAL_PROGRAM = 8 13 | PROGRAM_POINTER = 9 #Registro incial para escribir programas 14 | 15 | 16 | OXIGEN_MIN = 5 17 | OXIGEN_MAX = 6 18 | 19 | CLOUDINESS_MAX= 7 20 | 21 | alarms = { 22 | 'No hay alarmas presentes': 0, 23 | 'Parada de emergencia': 1, 24 | 'Generador de emergencia en marcha': 2, 25 | 'Alto nivel en pozo de bombeo inicial': 3, 26 | 'Alto nivel en pileta equalizadora': 4, 27 | 'Alto nivel en reactor SBR': 5, 28 | 'Alto nivel en pozo de bombeo de salida': 6, 29 | 'Fallo Bomba de pozo inicial 1': 7, 30 | 'Fallo Bomba de pozo inicial 2': 8, 31 | 'Fallo Bomba transvase 1': 9, 32 | 'Fallo Bomba transvase 2': 10, 33 | 'Fallo aireador equalizadora': 11, 34 | 'Fallo aireador SBR 1': 12, 35 | 'Fallo aireador SBR 2': 13, 36 | 'Fallo válvula de descarga': 14, 37 | 'Fallo válvula de espumas': 15, 38 | 'Fallo dosificador de cloro': 16, 39 | 'Fallo bomba pozo salida 1': 17, 40 | 'Fallo bomba pozo salida 2': 18, 41 | 'Fallo bomba recirculadora': 19, 42 | 'Oxigeno disuelto fuera de rango': 20, 43 | 'Turbiedad fuera de rango': 21, 44 | 'Fallo válvula de recirculación': 22, 45 | } 46 | 47 | notificaciones = { 48 | 'Reactor en carga aireada' : 25, 49 | 'Reactor en aireacion' : 26, 50 | 'Reactor en sedimentacion' : 27, 51 | 'Reactor en descarga' : 28, 52 | 'Reactor detenido' : 29, 53 | } 54 | 55 | niveles = { 56 | 'level' : 0, 57 | 'oxigen' : 1, 58 | 'cloudiness' : 2, 59 | 'volumen_tratado' : 3, 60 | 'volumen_tratado_parcial' : 4, 61 | 'carga_aireada' : 21, 62 | 'aireacion' : 22, 63 | 'sedimentacion' : 23, 64 | 'descarga' : 24, 65 | } 66 | 67 | parametros = { 68 | 'oxigen_min' : OXIGEN_MIN, 69 | 'oxigen_max' : OXIGEN_MAX, 70 | 'cloudiness_max' : CLOUDINESS_MAX, 71 | 'programa_actual' : ACTUAL_PROGRAM, 72 | } 73 | 74 | #define los formatters validos para los tipos de datos del plc 75 | formatters = { 76 | 'oxigen_min' : 0.01, 77 | 'oxigen_max' : 0.01, 78 | 'oxigen': 0.01, 79 | 'level' : 0.01, 80 | } 81 | 82 | -------------------------------------------------------------------------------- /arakur_ww/daemon.py: -------------------------------------------------------------------------------- 1 | #! -*-coding: utf8-*- 2 | import time 3 | import json 4 | import redis 5 | import utils 6 | from utils import RemoteCommand 7 | from config import PLC, REDIS 8 | from plc_interface import ArakurPLC 9 | import threading 10 | import logging 11 | 12 | logging.basicConfig() 13 | log = logging.getLogger() 14 | log.setLevel(logging.INFO) 15 | 16 | 17 | plc = ArakurPLC(PLC['host'], port=PLC['port']) 18 | plc.connect() 19 | 20 | broker = redis.StrictRedis(**REDIS) 21 | 22 | class DataAdquisitor(threading.Thread): 23 | def run(self): 24 | while True: 25 | #TODO logica para tratar de que el tiempo del ciclo sea constante 26 | #TODO guardar en la DB registros historicos 27 | #TODO agregar logging 28 | try: 29 | state = plc.obtener_estado() 30 | logging.info("Obtenidos datos del PLC") 31 | #guardamos en una key programa_ el hash del programa 32 | for n, programa in enumerate(state['programs'], 1): 33 | broker.hmset('programa_%s' % n, programa) 34 | 35 | broker.hmset('params', state['params']) 36 | broker.publish('plc_state', json.dumps(state)) 37 | logging.info("Estado publicado en canal 'plc_state'") 38 | except: 39 | logging.error("Ocurrió un error al obtener los datos desde el PLC") 40 | time.sleep(0.5) 41 | 42 | 43 | class CommandWatcher(threading.Thread): 44 | def run(self): 45 | #TODO implementar logging 46 | pubsub = broker.pubsub() 47 | pubsub.subscribe('commands') 48 | for message in pubsub.listen(): 49 | if message['type'] == 'message': 50 | logging.info("Se recibió el mensaje de ejecución remota %s", message['data']) 51 | command = RemoteCommand() 52 | command.unserialize(message['data']) 53 | try: 54 | success = command.execute(plc) 55 | except: 56 | success = False 57 | logging.error("No se pudo ejecutar la orden: '%s'", command.function_name) 58 | 59 | response = {'id':command.id, 'success': success} 60 | time.sleep(0.6) 61 | broker.publish('command-returns', json.dumps(response)) 62 | logging.info("enviada respuesta a comando: '%'", response) 63 | 64 | 65 | def run(): 66 | da = DataAdquisitor() 67 | da.daemon = True 68 | da.start() 69 | cw = CommandWatcher() 70 | cw.daemon = True 71 | cw.start() 72 | 73 | while threading.active_count() > 0: 74 | time.sleep(0.1) 75 | 76 | 77 | if __name__ == '__main__': 78 | run() 79 | -------------------------------------------------------------------------------- /arakur_ww/forms.py: -------------------------------------------------------------------------------- 1 | #! -*- coding: utf8 -*- 2 | from flask.ext.wtf import Form, TextField, PasswordField, validators, \ 3 | HiddenField, IntegerField, DecimalField 4 | 5 | 6 | class LoginForm(Form): 7 | username = TextField('Usuario', [validators.Required()]) 8 | password = PasswordField('Password', [validators.Required()]) 9 | 10 | def validate(self): 11 | rv = Form.validate(self) 12 | if not rv: 13 | return False 14 | if self.username.data != 'admin': 15 | self.username.errors.append('Nombre de usuario invalido') 16 | return False 17 | 18 | if self.password.data != 'xx': 19 | self.password.errors.append('Password Incorrecto') 20 | return False 21 | 22 | return True 23 | 24 | 25 | class ProgramForm(Form): 26 | carga_aireada = IntegerField(u'Carga Aireada', [ 27 | validators.Required("Campo obligatorio"), 28 | validators.NumberRange(1, 9999, "El valor debe estar entre %(min)s y %(max)s"), 29 | ]) 30 | aireacion = IntegerField(u'Aireación', [ 31 | validators.Required("Campo obligatorio"), 32 | validators.NumberRange(1, 9999, "El valor debe estar entre %(min)s y %(max)s"), 33 | ]) 34 | sedimentacion = IntegerField(u'Sedimentación', [ 35 | validators.Required("Campo obligatorio"), 36 | validators.NumberRange(1, 9999, "El valor debe estar entre %(min)s y %(max)s"), 37 | ]) 38 | descarga = IntegerField(u'Descarga', [ 39 | validators.Required("Campo obligatorio"), 40 | validators.NumberRange(1, 9999, "El valor debe estar entre %(min)s y %(max)s"), 41 | ]) 42 | 43 | 44 | class ParametersForm(Form): 45 | oxigen_min = DecimalField(u'Oxígeno Mínimo', [ 46 | validators.Required("Campo obligatorio"), 47 | validators.NumberRange(0.01, 99.99, "El valor debe estar entre %(min)s y %(max)s"), 48 | 49 | ]) 50 | oxigen_max = DecimalField(u'Oxígeno Máximo', [ 51 | validators.Required("Campo obligatorio"), 52 | validators.NumberRange(0.01, 99.99, "El valor debe estar entre %(min)s y %(max)s"), 53 | 54 | ]) 55 | cloudiness_max = IntegerField(u'Turbiedad Máxima', [ 56 | validators.Required("Campo obligatorio"), 57 | validators.NumberRange(1, 9999, "El valor debe estar entre %(min)s y %(max)s"), 58 | 59 | ]) 60 | 61 | -------------------------------------------------------------------------------- /arakur_ww/models.py: -------------------------------------------------------------------------------- 1 | from flask.ext.login import UserMixin 2 | 3 | 4 | #TODO implementar la clase real que va a hacer el login 5 | class User(UserMixin): 6 | id = u'admin' 7 | -------------------------------------------------------------------------------- /arakur_ww/plc_interface.py: -------------------------------------------------------------------------------- 1 | #! -*-coding: utf8 -*- 2 | import datetime 3 | from pymodbus.client.sync import ModbusTcpClient as ModbusClient 4 | from constants import * 5 | 6 | class ArakurPLC(ModbusClient): 7 | """Mantiene una interface de alto nivel con el PLC de operacion del SBR""" 8 | def __init__(self, *args, **kwargs): 9 | self.prev_marcas = [False] * MARK_COUNT 10 | self.change_times = [False] * MARK_COUNT 11 | super(ArakurPLC, self).__init__(*args, **kwargs) 12 | 13 | def detener_alarma(self): 14 | """Detiene la alarma""" 15 | return self._escribir_marca(M_ALARM_OFF, True) 16 | 17 | def iniciar_sbr(self): 18 | """Inicia el ciclo del sbr""" 19 | return self._escribir_marca(M_START_SBR, True) 20 | 21 | def detener_sbr(self): 22 | """Detiene el ciclo sbr""" 23 | return self._escribir_marca(M_STOP_SBR, True) 24 | 25 | def reset_acumulado(self): 26 | """Regresa la cuenta de volumen tratado a 0""" 27 | return self._escribir_marca(M_RESET_VOL, True) 28 | 29 | def obtener_estado(self): 30 | marcas = self.leer_marcas() 31 | registros = self.leer_registros() 32 | state = {} 33 | self._procesar_alarmas(state, marcas) 34 | self._procesar_eventos(state, marcas) 35 | self._procesar_valores_instantaneos(state, registros) 36 | self._procesar_programas(state, registros) 37 | self._procesar_parametros(state, registros) 38 | #faltaria timestamp para la informacion 39 | #guardamos las marcas viejas, para poder ver si cambiaron 40 | self.prev_marcas = marcas 41 | return state 42 | 43 | def _procesar_alarmas(self, state, marcas): 44 | state['alarms'] = [] 45 | state['new_alarms'] = [] 46 | now_str = self._now_str() 47 | 48 | for alarma, registro in alarms.iteritems(): 49 | 50 | alarm_dict = {'text':alarma, 'time':self.change_times[registro]} 51 | if marcas[registro] and not self.prev_marcas[registro]: 52 | alarm_dict['time'] = now_str 53 | state['new_alarms'].append(alarm_dict) 54 | state['alarms'].append(alarm_dict) 55 | self.change_times[registro] = now_str; 56 | elif marcas[registro]: 57 | #donde guardamos 58 | state['alarms'].append(alarm_dict) 59 | 60 | def _procesar_eventos(self, state, marcas): 61 | state['events'] = [] 62 | state['new_events'] = [] 63 | now_str = self._now_str() 64 | 65 | for evt, registro in notificaciones.iteritems(): 66 | event_dict = {'text':evt, 'time':self.change_times[registro]} 67 | if marcas[registro] and not self.prev_marcas[registro]: 68 | event_dict['time'] = now_str 69 | state['events'].append(event_dict) 70 | state['new_events'].append(event_dict) 71 | self.change_times[registro] = now_str 72 | elif marcas[registro]: 73 | state['events'].append(event_dict) 74 | 75 | def _procesar_valores_instantaneos(self, state, registros): 76 | state['instant_values'] = {} 77 | for element, registro in niveles.iteritems(): 78 | value = registros[registro] 79 | if formatters.has_key(element): 80 | value *= formatters[element] 81 | 82 | state['instant_values'][element] = value 83 | 84 | def _procesar_parametros(self, state, registros): 85 | state['params'] = {} 86 | for element, registro in parametros.iteritems(): 87 | value = registros[registro] 88 | if formatters.has_key(element): 89 | value *= formatters[element] 90 | state['params'][element] = value 91 | 92 | def _procesar_programas(self, state, registros): 93 | state['programs'] = [] 94 | values = ['carga_aireada', 'aireacion', 'sedimentacion', 'descarga'] 95 | for n in xrange(0, 3): 96 | program = {} 97 | for i, key in enumerate(values): 98 | registro = PROGRAM_POINTER + 4 * n + i 99 | program[key] = registros[registro] 100 | 101 | state['programs'].append(program) 102 | 103 | def leer_marcas(self): 104 | """Lee las marcas de memoria del twido""" 105 | response = self.read_coils(0, MARK_COUNT) 106 | if response.function_code < ERROR_CODE: 107 | return response.bits 108 | else: 109 | return False 110 | 111 | def leer_registros(self): 112 | """Lee los valores de todos los registros""" 113 | 114 | response = self.read_input_registers(0, REGISTER_COUNT) 115 | if response.function_code < ERROR_CODE: 116 | return response.registers 117 | else: 118 | return False 119 | 120 | def actualizar_programa(self, numero, carga_aireada, aireacion, sedimentacion, descarga): 121 | """Actualiza la configuracion para el programa dado.""" 122 | if numero in xrange(1, 4): 123 | direccion = PROGRAM_POINTER + (numero - 1) * 4 #Aritmetica para escribir el programa 124 | 125 | response = self.write_registers(direccion, [carga_aireada, aireacion, sedimentacion, descarga]) 126 | return response.function_code < ERROR_CODE 127 | 128 | return False 129 | 130 | def cambiar_programa(self, valor): 131 | return self._escribir_registro(ACTUAL_PROGRAM, valor) 132 | 133 | def actualizar_parametros(self, min_oxigeno, max_oxigeno, max_turbiedad): 134 | response = self.write_registers(OXIGEN_MIN, [min_oxigeno, max_oxigeno, max_turbiedad]) 135 | return response.function_code < ERROR_CODE 136 | 137 | def _escribir_registro(self, numero, valor): 138 | response = self.write_register(numero, valor) 139 | return response.function_code < ERROR_CODE 140 | 141 | def _escribir_marca(self, numero, valor): 142 | response = self.write_coil(numero, valor) 143 | return response.function_code < ERROR_CODE 144 | 145 | def iniciar_programa(self, programa): 146 | self.cambiar_programa(programa) 147 | return self.iniciar_sbr() 148 | def _now_str(self): 149 | return datetime.datetime.now().strftime("%d/%m/%Y %H:%M:%S") 150 | -------------------------------------------------------------------------------- /arakur_ww/static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joac/ArakurWW/28670cb4fa4cda4d91f48d8decc0c96794fde291/arakur_ww/static/.gitkeep -------------------------------------------------------------------------------- /arakur_ww/static/EventSource.js: -------------------------------------------------------------------------------- 1 | ;(function (global) { 2 | 3 | if ("EventSource" in global) return; 4 | 5 | var reTrim = /^(\s|\u00A0)+|(\s|\u00A0)+$/g; 6 | 7 | var EventSource = function (url) { 8 | var eventsource = this, 9 | interval = 500, // polling interval 10 | lastEventId = null, 11 | cache = ''; 12 | 13 | if (!url || typeof url != 'string') { 14 | throw new SyntaxError('Not enough arguments'); 15 | } 16 | 17 | this.URL = url; 18 | this.readyState = this.CONNECTING; 19 | this._pollTimer = null; 20 | this._xhr = null; 21 | 22 | function pollAgain(interval) { 23 | eventsource._pollTimer = setTimeout(function () { 24 | poll.call(eventsource); 25 | }, interval); 26 | } 27 | 28 | function poll() { 29 | try { // force hiding of the error message... insane? 30 | if (eventsource.readyState == eventsource.CLOSED) return; 31 | 32 | // NOTE: IE7 and upwards support 33 | var xhr = new XMLHttpRequest(); 34 | xhr.open('GET', eventsource.URL, true); 35 | xhr.setRequestHeader('Accept', 'text/event-stream'); 36 | xhr.setRequestHeader('Cache-Control', 'no-cache'); 37 | // we must make use of this on the server side if we're working with Android - because they don't trigger 38 | // readychange until the server connection is closed 39 | xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); 40 | 41 | if (lastEventId != null) xhr.setRequestHeader('Last-Event-ID', lastEventId); 42 | cache = ''; 43 | 44 | xhr.timeout = 50000; 45 | xhr.onreadystatechange = function () { 46 | if ((this.readyState == 3 || this.readyState == 4) && this.status == 200) { 47 | // on success 48 | if (eventsource.readyState == eventsource.CONNECTING) { 49 | eventsource.readyState = eventsource.OPEN; 50 | eventsource.dispatchEvent('open', { type: 'open' }); 51 | } 52 | 53 | var responseText = ''; 54 | try { 55 | responseText = this.responseText || ''; 56 | } catch (e) {} 57 | 58 | // process this.responseText 59 | var parts = responseText.substr(cache.length).split("\n"), 60 | eventType = 'message', 61 | data = [], 62 | i = 0, 63 | line = ''; 64 | 65 | cache = responseText; 66 | 67 | // TODO handle 'event' (for buffer name), retry 68 | for (; i < parts.length; i++) { 69 | line = parts[i].replace(reTrim, ''); 70 | if (line.indexOf('event') == 0) { 71 | eventType = line.replace(/event:?\s*/, ''); 72 | } else if (line.indexOf('retry') == 0) { 73 | retry = parseInt(line.replace(/retry:?\s*/, '')); 74 | if(!isNaN(retry)) { interval = retry; } 75 | } else if (line.indexOf('data') == 0) { 76 | data.push(line.replace(/data:?\s*/, '')); 77 | } else if (line.indexOf('id:') == 0) { 78 | lastEventId = line.replace(/id:?\s*/, ''); 79 | } else if (line.indexOf('id') == 0) { // this resets the id 80 | lastEventId = null; 81 | } else if (line == '') { 82 | if (data.length) { 83 | var event = new MessageEvent(data.join('\n'), eventsource.url, lastEventId); 84 | eventsource.dispatchEvent(eventType, event); 85 | data = []; 86 | eventType = 'message'; 87 | } 88 | } 89 | } 90 | 91 | if (this.readyState == 4) pollAgain(interval); 92 | // don't need to poll again, because we're long-loading 93 | } else if (eventsource.readyState !== eventsource.CLOSED) { 94 | if (this.readyState == 4) { // and some other status 95 | // dispatch error 96 | eventsource.readyState = eventsource.CONNECTING; 97 | eventsource.dispatchEvent('error', { type: 'error' }); 98 | pollAgain(interval); 99 | } else if (this.readyState == 0) { // likely aborted 100 | pollAgain(interval); 101 | } else { 102 | } 103 | } 104 | }; 105 | 106 | xhr.send(); 107 | 108 | setTimeout(function () { 109 | if (true || xhr.readyState == 3) xhr.abort(); 110 | }, xhr.timeout); 111 | 112 | eventsource._xhr = xhr; 113 | 114 | } catch (e) { // in an attempt to silence the errors 115 | eventsource.dispatchEvent('error', { type: 'error', data: e.message }); // ??? 116 | } 117 | }; 118 | 119 | poll(); // init now 120 | }; 121 | 122 | EventSource.prototype = { 123 | close: function () { 124 | // closes the connection - disabling the polling 125 | this.readyState = this.CLOSED; 126 | clearInterval(this._pollTimer); 127 | this._xhr.abort(); 128 | }, 129 | CONNECTING: 0, 130 | OPEN: 1, 131 | CLOSED: 2, 132 | dispatchEvent: function (type, event) { 133 | var handlers = this['_' + type + 'Handlers']; 134 | if (handlers) { 135 | for (var i = 0; i < handlers.length; i++) { 136 | handlers[i].call(this, event); 137 | } 138 | } 139 | 140 | if (this['on' + type]) { 141 | this['on' + type].call(this, event); 142 | } 143 | }, 144 | addEventListener: function (type, handler) { 145 | if (!this['_' + type + 'Handlers']) { 146 | this['_' + type + 'Handlers'] = []; 147 | } 148 | 149 | this['_' + type + 'Handlers'].push(handler); 150 | }, 151 | removeEventListener: function () { 152 | // TODO 153 | }, 154 | onerror: null, 155 | onmessage: null, 156 | onopen: null, 157 | readyState: 0, 158 | URL: '' 159 | }; 160 | 161 | var MessageEvent = function (data, origin, lastEventId) { 162 | this.data = data; 163 | this.origin = origin; 164 | this.lastEventId = lastEventId || ''; 165 | }; 166 | 167 | MessageEvent.prototype = { 168 | data: null, 169 | type: 'message', 170 | lastEventId: '', 171 | origin: '' 172 | }; 173 | 174 | if ('module' in global) module.exports = EventSource; 175 | global.EventSource = EventSource; 176 | 177 | })(this); 178 | -------------------------------------------------------------------------------- /arakur_ww/static/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joac/ArakurWW/28670cb4fa4cda4d91f48d8decc0c96794fde291/arakur_ww/static/background.png -------------------------------------------------------------------------------- /arakur_ww/static/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: url('/static/background.png') repeat fixed; 3 | } 4 | 5 | .container { 6 | background-color: rgba(255, 255, 255, 0.6); 7 | padding: 15px; 8 | } 9 | 10 | .nav-tabs > .active > a, .nav-tabs > .active > a:hover { 11 | background-color: rgba(255, 255, 255, 0.2); 12 | 13 | } 14 | 15 | .centered { 16 | text-align: center; 17 | } 18 | 19 | .form-actions { 20 | background-color: rgba(245,245,245, 0.5); 21 | } 22 | 23 | .navbar-inner { 24 | background-color: rgba(69, 174, 234, 0.5); 25 | background-image: none; 26 | } 27 | 28 | .navbar .nav > li > a:focus, .navbar .nav > li > a:hover { 29 | background-color: rgba(22, 132, 194, 0.5); 30 | 31 | } 32 | -------------------------------------------------------------------------------- /arakur_ww/static/event_manager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utiliza un event stream para obtener el estado de 3 | * la planta y si se le pasa, tambiene ejecura una funcion 4 | * al recibir un mensaje 5 | */ 6 | function state_handler(custom_function){ 7 | var source = new EventSource('/stream'); 8 | source.onmessage = function(event){ 9 | window.state = JSON.parse(event.data); 10 | if (typeof custom_function === 'function'){ 11 | custom_function(window.state); 12 | }; 13 | }; 14 | }; 15 | function update_tag(name, value, postfix){ 16 | var postfix = postfix || ''; 17 | if (Math.round(value) !== value){ 18 | value = value.toFixed(2); 19 | }; 20 | $("#" + name).text(value + " " + postfix); 21 | }; 22 | /** 23 | * Actualiza una barra de progreso de bootstrap 24 | */ 25 | function update_bar(name, value, postfix, max){ 26 | var max = max || 100; 27 | var percent = (value / max ) * 100; 28 | if (percent > 100){ 29 | percent = 100; 30 | }; 31 | $("#" + name).css('width', percent + "%"); 32 | update_tag(name, value, postfix); 33 | update_tag(name + "_val", value, postfix); 34 | }; 35 | 36 | function _crear_div_alarma(text, time){ 37 | var div = document.createElement('div'); 38 | var boton = document.createElement('button'); 39 | boton.type = 'button'; 40 | boton.setAttribute('data-dismiss', 'alert'); 41 | boton.classList.add('close'); 42 | boton.innerHTML = '×'; 43 | div.classList.add('alert'); 44 | div.appendChild(boton); 45 | timestamp = document.createElement('strong'); 46 | timestamp.innerText = time; 47 | div.appendChild(timestamp); 48 | div.innerHTML += " " + text; 49 | 50 | return div; 51 | }; 52 | 53 | function set_alert(text, time){ 54 | var container = $('#alert_container'); 55 | var div = _crear_div_alarma(text, time); 56 | container.prepend(div); 57 | }; 58 | 59 | 60 | function set_notify(text, time){ 61 | var container = $('#event_container'); 62 | var div = _crear_div_alarma(text, time); 63 | div.classList.add('alert-success'); 64 | container.prepend(div); 65 | }; 66 | -------------------------------------------------------------------------------- /arakur_ww/static/flask-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joac/ArakurWW/28670cb4fa4cda4d91f48d8decc0c96794fde291/arakur_ww/static/flask-logo.png -------------------------------------------------------------------------------- /arakur_ww/static/gpl-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joac/ArakurWW/28670cb4fa4cda4d91f48d8decc0c96794fde291/arakur_ww/static/gpl-logo.png -------------------------------------------------------------------------------- /arakur_ww/static/jquery.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery v1.9.0 | (c) 2005, 2012 jQuery Foundation, Inc. | jquery.org/license */(function(e,t){"use strict";function n(e){var t=e.length,n=st.type(e);return st.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}function r(e){var t=Tt[e]={};return st.each(e.match(lt)||[],function(e,n){t[n]=!0}),t}function i(e,n,r,i){if(st.acceptData(e)){var o,a,s=st.expando,u="string"==typeof n,l=e.nodeType,c=l?st.cache:e,f=l?e[s]:e[s]&&s;if(f&&c[f]&&(i||c[f].data)||!u||r!==t)return f||(l?e[s]=f=K.pop()||st.guid++:f=s),c[f]||(c[f]={},l||(c[f].toJSON=st.noop)),("object"==typeof n||"function"==typeof n)&&(i?c[f]=st.extend(c[f],n):c[f].data=st.extend(c[f].data,n)),o=c[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[st.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[st.camelCase(n)])):a=o,a}}function o(e,t,n){if(st.acceptData(e)){var r,i,o,a=e.nodeType,u=a?st.cache:e,l=a?e[st.expando]:st.expando;if(u[l]){if(t&&(r=n?u[l]:u[l].data)){st.isArray(t)?t=t.concat(st.map(t,st.camelCase)):t in r?t=[t]:(t=st.camelCase(t),t=t in r?[t]:t.split(" "));for(i=0,o=t.length;o>i;i++)delete r[t[i]];if(!(n?s:st.isEmptyObject)(r))return}(n||(delete u[l].data,s(u[l])))&&(a?st.cleanData([e],!0):st.support.deleteExpando||u!=u.window?delete u[l]:u[l]=null)}}}function a(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(Nt,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:wt.test(r)?st.parseJSON(r):r}catch(o){}st.data(e,n,r)}else r=t}return r}function s(e){var t;for(t in e)if(("data"!==t||!st.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}function u(){return!0}function l(){return!1}function c(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}function f(e,t,n){if(t=t||0,st.isFunction(t))return st.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return st.grep(e,function(e){return e===t===n});if("string"==typeof t){var r=st.grep(e,function(e){return 1===e.nodeType});if(Wt.test(t))return st.filter(t,r,!n);t=st.filter(t,r)}return st.grep(e,function(e){return st.inArray(e,t)>=0===n})}function p(e){var t=zt.split("|"),n=e.createDocumentFragment();if(n.createElement)for(;t.length;)n.createElement(t.pop());return n}function d(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function h(e){var t=e.getAttributeNode("type");return e.type=(t&&t.specified)+"/"+e.type,e}function g(e){var t=nn.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function m(e,t){for(var n,r=0;null!=(n=e[r]);r++)st._data(n,"globalEval",!t||st._data(t[r],"globalEval"))}function y(e,t){if(1===t.nodeType&&st.hasData(e)){var n,r,i,o=st._data(e),a=st._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)st.event.add(t,n,s[n][r])}a.data&&(a.data=st.extend({},a.data))}}function v(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!st.support.noCloneEvent&&t[st.expando]){r=st._data(t);for(i in r.events)st.removeEvent(t,i,r.handle);t.removeAttribute(st.expando)}"script"===n&&t.text!==e.text?(h(t).text=e.text,g(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),st.support.html5Clone&&e.innerHTML&&!st.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Zt.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}function b(e,n){var r,i,o=0,a=e.getElementsByTagName!==t?e.getElementsByTagName(n||"*"):e.querySelectorAll!==t?e.querySelectorAll(n||"*"):t;if(!a)for(a=[],r=e.childNodes||e;null!=(i=r[o]);o++)!n||st.nodeName(i,n)?a.push(i):st.merge(a,b(i,n));return n===t||n&&st.nodeName(e,n)?st.merge([e],a):a}function x(e){Zt.test(e.type)&&(e.defaultChecked=e.checked)}function T(e,t){if(t in e)return t;for(var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=Nn.length;i--;)if(t=Nn[i]+n,t in e)return t;return r}function w(e,t){return e=t||e,"none"===st.css(e,"display")||!st.contains(e.ownerDocument,e)}function N(e,t){for(var n,r=[],i=0,o=e.length;o>i;i++)n=e[i],n.style&&(r[i]=st._data(n,"olddisplay"),t?(r[i]||"none"!==n.style.display||(n.style.display=""),""===n.style.display&&w(n)&&(r[i]=st._data(n,"olddisplay",S(n.nodeName)))):r[i]||w(n)||st._data(n,"olddisplay",st.css(n,"display")));for(i=0;o>i;i++)n=e[i],n.style&&(t&&"none"!==n.style.display&&""!==n.style.display||(n.style.display=t?r[i]||"":"none"));return e}function C(e,t,n){var r=mn.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function k(e,t,n,r,i){for(var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;4>o;o+=2)"margin"===n&&(a+=st.css(e,n+wn[o],!0,i)),r?("content"===n&&(a-=st.css(e,"padding"+wn[o],!0,i)),"margin"!==n&&(a-=st.css(e,"border"+wn[o]+"Width",!0,i))):(a+=st.css(e,"padding"+wn[o],!0,i),"padding"!==n&&(a+=st.css(e,"border"+wn[o]+"Width",!0,i)));return a}function E(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=ln(e),a=st.support.boxSizing&&"border-box"===st.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=un(e,t,o),(0>i||null==i)&&(i=e.style[t]),yn.test(i))return i;r=a&&(st.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+k(e,t,n||(a?"border":"content"),r,o)+"px"}function S(e){var t=V,n=bn[e];return n||(n=A(e,t),"none"!==n&&n||(cn=(cn||st("