├── lib
├── __init__.py
├── init
│ ├── kiln-controller.service
│ └── smoker.service
└── ovenWatcher.py
├── docs
├── kiln-tuner-example.png
├── api.md
├── logs.md
├── watcher.md
├── supported-boards.md
├── old-to-new.md
├── schedule.md
├── pid_tuning.md
├── ziegler_tuning.md
└── troubleshooting.md
├── public
├── assets
│ ├── css
│ │ ├── select2.png
│ │ ├── select2x2.png
│ │ ├── select2-spinner.gif
│ │ ├── state.css
│ │ ├── select2-bootstrap.css
│ │ ├── bootstrap-modal.css
│ │ ├── picoreflow.css
│ │ ├── bootstrap-theme.min.css
│ │ └── select2.css
│ ├── fonts
│ │ ├── tables.eot
│ │ ├── tables.ttf
│ │ ├── tables.woff
│ │ ├── digital-7-webfont.eot
│ │ ├── digital-7-webfont.ttf
│ │ ├── digital-7-webfont.woff
│ │ ├── glyphicons-halflings-regular.eot
│ │ ├── glyphicons-halflings-regular.ttf
│ │ ├── glyphicons-halflings-regular.woff
│ │ └── digital-7-webfont.svg
│ ├── images
│ │ ├── rpi.png
│ │ ├── ssr.png
│ │ ├── ks-1018.png
│ │ ├── page_bg.png
│ │ ├── breadboard.png
│ │ ├── max31855.png
│ │ ├── panel_bg.png
│ │ ├── schematic.png
│ │ ├── kiln-running.png
│ │ ├── kiln-schedule.png
│ │ └── k-type-thermocouple.png
│ └── js
│ │ ├── jquery.event.drag-2.2.js
│ │ ├── jquery.bootstrap-growl.min.js
│ │ ├── jquery.flot.resize.js
│ │ ├── jquery.flot.draggable.js
│ │ └── state.js
├── state.html
└── index.html
├── storage
└── profiles
│ ├── test-200-250.json
│ ├── test-fast.json
│ ├── cone-05-fast-bisque.json
│ ├── cone-6-long-glaze.json
│ └── cone-05-long-bisque.json
├── Test
├── test-fast.json
├── test-cases.json
└── test_Profile.py
├── start-on-boot
├── .gitignore
├── ziplogs
├── requirements.txt
├── test-output.py
├── test-thermocouple.py
├── watcher.py
├── kiln-logger.py
├── gpioreadall.py
├── kiln-tuner.py
├── README.md
├── config.py
└── kiln-controller.py
/lib/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/kiln-tuner-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/docs/kiln-tuner-example.png
--------------------------------------------------------------------------------
/public/assets/css/select2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/css/select2.png
--------------------------------------------------------------------------------
/public/assets/fonts/tables.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/fonts/tables.eot
--------------------------------------------------------------------------------
/public/assets/fonts/tables.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/fonts/tables.ttf
--------------------------------------------------------------------------------
/public/assets/images/rpi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/rpi.png
--------------------------------------------------------------------------------
/public/assets/images/ssr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/ssr.png
--------------------------------------------------------------------------------
/public/assets/css/select2x2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/css/select2x2.png
--------------------------------------------------------------------------------
/public/assets/fonts/tables.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/fonts/tables.woff
--------------------------------------------------------------------------------
/public/assets/images/ks-1018.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/ks-1018.png
--------------------------------------------------------------------------------
/public/assets/images/page_bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/page_bg.png
--------------------------------------------------------------------------------
/public/assets/images/breadboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/breadboard.png
--------------------------------------------------------------------------------
/public/assets/images/max31855.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/max31855.png
--------------------------------------------------------------------------------
/public/assets/images/panel_bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/panel_bg.png
--------------------------------------------------------------------------------
/public/assets/images/schematic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/schematic.png
--------------------------------------------------------------------------------
/public/assets/css/select2-spinner.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/css/select2-spinner.gif
--------------------------------------------------------------------------------
/public/assets/images/kiln-running.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/kiln-running.png
--------------------------------------------------------------------------------
/public/assets/images/kiln-schedule.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/kiln-schedule.png
--------------------------------------------------------------------------------
/public/assets/fonts/digital-7-webfont.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/fonts/digital-7-webfont.eot
--------------------------------------------------------------------------------
/public/assets/fonts/digital-7-webfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/fonts/digital-7-webfont.ttf
--------------------------------------------------------------------------------
/public/assets/fonts/digital-7-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/fonts/digital-7-webfont.woff
--------------------------------------------------------------------------------
/public/assets/js/jquery.event.drag-2.2.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/js/jquery.event.drag-2.2.js
--------------------------------------------------------------------------------
/public/assets/images/k-type-thermocouple.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/images/k-type-thermocouple.png
--------------------------------------------------------------------------------
/storage/profiles/test-200-250.json:
--------------------------------------------------------------------------------
1 | {"data": [[0, 100], [480, 200], [2000, 200], [2300, 250], [3600, 250]], "type": "profile", "name": "test-200-250"}
2 |
--------------------------------------------------------------------------------
/Test/test-fast.json:
--------------------------------------------------------------------------------
1 | {"data": [[0, 200], [3600, 200], [10800, 2000], [14400, 2250], [16400, 2250], [19400, 700]], "type": "profile", "name": "test-fast"}
2 |
--------------------------------------------------------------------------------
/start-on-boot:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | sudo cp /home/pi/kiln-controller/lib/init/kiln-controller.service /etc/systemd/system/
3 | sudo systemctl enable kiln-controller
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *~
3 | \#*
4 | .#*
5 | *.swp
6 | .DStore/
7 | thumbs.db
8 | #storage/profiles
9 | #config.py
10 | .idea/*
11 | state.json
12 | venv/*
13 |
--------------------------------------------------------------------------------
/public/assets/fonts/glyphicons-halflings-regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/fonts/glyphicons-halflings-regular.eot
--------------------------------------------------------------------------------
/public/assets/fonts/glyphicons-halflings-regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/fonts/glyphicons-halflings-regular.ttf
--------------------------------------------------------------------------------
/storage/profiles/test-fast.json:
--------------------------------------------------------------------------------
1 | {"data": [[0, 200], [3600, 200], [10800, 2000], [14400, 2250], [16400, 2250], [19400, 70]], "type": "profile", "name": "test-fast"}
2 |
--------------------------------------------------------------------------------
/Test/test-cases.json:
--------------------------------------------------------------------------------
1 | {"data": [[0, 200], [3600, 200], [4200, 500], [10800, 500], [14400, 2250], [16400, 2000], [19400, 2250]], "type": "profile", "name": "test-fast"}
2 |
--------------------------------------------------------------------------------
/public/assets/fonts/glyphicons-halflings-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jbruce12000/kiln-controller/HEAD/public/assets/fonts/glyphicons-halflings-regular.woff
--------------------------------------------------------------------------------
/storage/profiles/cone-05-fast-bisque.json:
--------------------------------------------------------------------------------
1 | {"type": "profile", "data": [[0, 65], [600, 200], [2088, 250], [5688, 250], [23135, 1733], [28320, 1888], [30900, 1888]], "name": "cone-05-fast-bisque"}
--------------------------------------------------------------------------------
/storage/profiles/cone-6-long-glaze.json:
--------------------------------------------------------------------------------
1 | {"data": [[0, 65], [600, 200], [7200, 250], [25200, 1976], [32880, 2232], [33480, 2232], [36780, 1832], [48780, 1400]], "type": "profile", "name": "cone-6-long-glaze"}
--------------------------------------------------------------------------------
/storage/profiles/cone-05-long-bisque.json:
--------------------------------------------------------------------------------
1 | {"data": [[0, 65], [600, 200], [7500, 250], [14340, 600], [24840, 1300], [45840, 1650], [46800, 1708], [52800, 1888], [54600, 1888]], "type": "profile", "name": "cone-05-long-bisque"}
--------------------------------------------------------------------------------
/lib/init/kiln-controller.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=kiln-controller
3 |
4 | [Service]
5 | ExecStart=/home/pi/kiln-controller/venv/bin/python /home/pi/kiln-controller/kiln-controller.py
6 |
7 | [Install]
8 | WantedBy=multi-user.target
9 |
--------------------------------------------------------------------------------
/lib/init/smoker.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=kiln-controller
3 |
4 | [Service]
5 | ExecStart=/home/pi/kiln-controller/venv/bin/python /home/pi/kiln-controller/kiln-controller.py
6 | ExecStartPost=/bin/sleep 5
7 | ExecStartPost=curl -d '{"cmd":"run", "profile":"pork-butt","startat":10}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
8 |
9 | [Install]
10 | WantedBy=multi-user.target
11 |
--------------------------------------------------------------------------------
/ziplogs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | echo "----------------------------------------------"
4 | echo "| Writing all kiln logs to ./kiln.logs.gz... |"
5 | echo "----------------------------------------------"
6 | zcat -f /var/log/* 2>/dev/null|strings|grep -E "(INFO|WARN|ERROR) (oven|kiln-controller|gevent)"|sort|uniq|gzip > kiln.logs.gz
7 |
8 | ls -la kiln.logs.gz
9 |
10 | # for me to use to grab stats
11 | #gzip -cd kiln.logs.gz|grep "INFO oven: temp="|sed 's/^.*\(....-..-.. ..:..:..,... INFO oven: temp=\)/\1/'|sort|uniq
12 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | setuptools
2 | greenlet
3 | bottle
4 | gevent
5 | gevent-websocket
6 | websocket-client
7 | requests
8 |
9 | # for folks running raspberry pis
10 | # we have no proof of anyone using another board yet, but when that
11 | # happens, you might want to comment this out.
12 | RPi.GPIO
13 |
14 | # List of all supported adafruit modules for thermocouples
15 | adafruit-circuitpython-max31855
16 | adafruit-circuitpython-max31856
17 |
18 | # for folks using sw spi (bit banging)
19 | adafruit-circuitpython-bitbangio
20 |
21 | # untested - for PT100 platinum thermocouples
22 | #adafruit-circuitpython-max31865
23 |
24 | # untested - for mcp9600 and mcp9601
25 | #adafruit-circuitpython-mcp9600
26 |
--------------------------------------------------------------------------------
/public/assets/css/state.css:
--------------------------------------------------------------------------------
1 | .container {
2 | border-radius: 5px;
3 | background: #888888;
4 | //background: #CCCCCC;
5 | padding: 2px;
6 | display: flex;
7 | flex-direction: row;
8 | align-items: center;
9 | width: max-content;
10 | font-family: sans-serif;
11 | margin: 4px;
12 | }
13 |
14 | .stat {
15 | padding: 2px;
16 | border-radius: 5px;
17 | font-size: 40pt;
18 | color: #FFFFFF;
19 | }
20 |
21 | .stattxt {
22 | padding: 7px;
23 | border-radius: 5px;
24 | font-size: 40pt;
25 | background: #BBBBBB;
26 | //color: #FFFFFF;
27 | color: #888888;
28 | margin: 1px 2px 1px 2px;
29 | }
30 |
31 | .top {
32 | border-radius: 5px 5px 0px 0px;
33 | background: #0000CC;
34 | padding: 4px;
35 | color: #CCCCCC;
36 | text-align: center;
37 | font-size: 18pt;
38 | }
39 |
40 | .bottom {
41 | border-radius: 0px 0px 5px 5px;
42 | background: #BBBBBB;
43 | padding: 4px;
44 | text-align: center;
45 | color: #0000CC;
46 | font-size: 20pt;
47 | }
48 |
--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------
1 | start a run
2 |
3 | curl -d '{"cmd":"run", "profile":"cone-05-long-bisque"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
4 |
5 | skip the first part of a run
6 | restart the kiln on a specific profile and start at minute 60
7 |
8 | curl -d '{"cmd":"run", "profile":"cone-05-long-bisque","startat":60}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
9 |
10 | stop a schedule
11 |
12 | curl -d '{"cmd":"stop"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
13 |
14 | post a memo
15 |
16 | curl -d '{"cmd":"memo", "memo":"some significant message"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
17 |
18 | stats for currently running schedule
19 |
20 | curl -X GET http://0.0.0.0:8081/api/stats
21 |
22 | pause a run (maintain current temperature until resume)
23 |
24 | curl -d '{"cmd":"pause"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
25 |
26 | resume a paused run
27 |
28 | curl -d '{"cmd":"resume"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
29 |
--------------------------------------------------------------------------------
/public/assets/js/jquery.bootstrap-growl.min.js:
--------------------------------------------------------------------------------
1 | (function(){var t;t=jQuery,t.bootstrapGrowl=function(e,s){var a,o,l;switch(s=t.extend({},t.bootstrapGrowl.default_options,s),a=t("
"),a.attr("class","bootstrap-growl alert"),s.type&&a.addClass("alert-"+s.type),s.allow_dismiss&&a.append('
× '),a.append(e),s.top_offset&&(s.offset={from:"top",amount:s.top_offset}),l=s.offset.amount,t(".bootstrap-growl").each(function(){return l=Math.max(l,parseInt(t(this).css(s.offset.from))+t(this).outerHeight()+s.stackup_spacing)}),o={position:"body"===s.ele?"fixed":"absolute",margin:0,"z-index":"9999",display:"none"},o[s.offset.from]=l+"px",a.css(o),"auto"!==s.width&&a.css("width",s.width+"px"),t(s.ele).append(a),s.align){case"center":a.css({left:"50%","margin-left":"-"+a.outerWidth()/2+"px"});break;case"left":a.css("left","20px");break;default:a.css("right","20px")}return a.fadeIn(),s.delay>0&&a.delay(s.delay).fadeOut(function(){return t(this).alert("close")}),a},t.bootstrapGrowl.default_options={ele:"body",type:"info",offset:{from:"top",amount:20},align:"right",width:250,delay:4e3,allow_dismiss:!0,stackup_spacing:10}}).call(this);
2 |
--------------------------------------------------------------------------------
/docs/logs.md:
--------------------------------------------------------------------------------
1 | Logs for a Kiln Run
2 | ===================
3 |
4 | Logs from the app on the pi go to **/var/log/daemon.log** and look like this...
5 |
6 | May 14 22:36:09 kiln python[350]: 2022-05-14 22:36:09,824 INFO oven: temp=1888.40, target=1888.00, error=-0.40, pid=54.33, p=-3.99, i=69.11, d=-10.79, heat_on=1.09, heat_off=0.91, run_time=27250, total_time=27335, time_left=84
7 |
8 | | log variable | meaning |
9 | | ------------ | ------- |
10 | |temp | temperature read by thermocouple |
11 | |target | target temperature |
12 | |error | difference between target and temp |
13 | |pid | pid value for that 2s |
14 | |p | proportional value for that 2s |
15 | |i | integral value for that 2s |
16 | |d | derivative value for that 2s |
17 | |heat_on | number of seconds the elements were on |
18 | |heat_off | number of seconds the elements were off |
19 | |run_time | seconds since start of schedule|
20 | |total_time | total seconds for schedule |
21 | |time_left | seconds left till the end of schedule|
22 |
23 |
24 | If you need to send kiln logs to someone for troubleshooting:
25 |
26 | ```
27 | cd kiln-controller
28 | ./ziplogs
29 | ```
30 |
31 | that creates a file named kiln.logs.gz in the current directory suitable for
32 | posting.
33 |
34 | Here is a project I use to read logs to help troubleshoot logs you post...
35 |
36 | https://github.com/jbruce12000/kiln-stats
37 |
--------------------------------------------------------------------------------
/test-output.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import config
3 | import adafruit_max31855
4 | import digitalio
5 | import time
6 | import datetime
7 |
8 | try:
9 | import board
10 | except NotImplementedError:
11 | print("not running a recognized blinka board, exiting...")
12 | import sys
13 | sys.exit()
14 |
15 | ########################################################################
16 | #
17 | # To test your gpio output to control a relay...
18 | #
19 | # Edit config.py and set the following in that file to match your
20 | # hardware setup: gpio_heat, gpio_heat_invert
21 | #
22 | # then run this script...
23 | #
24 | # ./test-output.py
25 | #
26 | # This will switch the output on for five seconds and then off for five
27 | # seconds. Measure the voltage between the output and any ground pin.
28 | # You can also run ./gpioreadall.py in another window to see the voltage
29 | # on your configured pin change.
30 | ########################################################################
31 |
32 | heater = digitalio.DigitalInOut(config.gpio_heat)
33 | heater.direction = digitalio.Direction.OUTPUT
34 | off = config.gpio_heat_invert
35 | on = not off
36 |
37 | print("\nboard: %s" % (board.board_id))
38 | print("heater configured as config.gpio_heat = %s BCM pin\n" % (config.gpio_heat))
39 | print("heater output pin configured as invert = %r\n" % (config.gpio_heat_invert))
40 |
41 | while True:
42 | heater.value = on
43 | print("%s heater on" % datetime.datetime.now())
44 | time.sleep(5)
45 | heater.value = off
46 | print("%s heater off" % datetime.datetime.now())
47 | time.sleep(5)
48 |
--------------------------------------------------------------------------------
/docs/watcher.md:
--------------------------------------------------------------------------------
1 | ### Watcher
2 |
3 | watcher.py is a watchdog for your kiln. It is a stand-alone python script that, every few seconds, verifies the kiln-controller.py process is running, and within a certain acceptable temperature range. By default it checks every 10s and after six failed checks, it sends a message to a slack channel. It will send a message every 60s until the problem[s] are solved. It can run on any network, but needs to be able to access the kiln and slack.
4 |
5 | Here are the configuration items to potentially set in that script:
6 |
7 | | Variable | Purpose | Required | Default |
8 | | ------------- |-------------- | --------- | ------- |
9 | | kiln_url | the url of the stats api endpoint | Yes | None |
10 | | slack_hook_url| the url of the slack channel to post failures | Yes | None |
11 | | bad_check_limit | send message after this many failures | No | 6 |
12 | | temp_error_limit | consider it a failure if temperature is this far off | No | 10 |
13 | | sleepfor | wait this many seconds between checks | No | 10 |
14 |
15 | ### Slack
16 |
17 | [Slack](https://slack.com/) is a free messaging platform. It is used to send alerts when the watcher finds problems with your kiln.
18 |
19 | 1. Sign up for a slack account
20 | 2. Create a workspace, doesn't matter what you call it
21 | 3. Create a channel in that workspace
22 | 4. Set up an [incoming web hook](https://slack.com/help/articles/115005265063-Incoming-webhooks-for-Slack) in that channel.
23 | 5. Grab the URL for that web hook and use it to set the slack_hook_url in the configuration
24 |
25 | If you configured slack, you can test it by starting the watcher without the kiln-controller running and after six failures to reach the kiln, it will send a notification to slack.
26 |
27 | I have the slack app on my android phone. This enables me to receive notifications every time I receive a slack message on a specific channel. This way, I can set my phone on vibrate, put it in my pocket and go mow the lawn... yet still know that my kiln is good.
28 |
29 |
--------------------------------------------------------------------------------
/docs/supported-boards.md:
--------------------------------------------------------------------------------
1 | This is a list of all the boards that have SPI support in blinka. Any of
2 | these could be used to control the kiln for this project. I have experience
3 | only with Raspberry PI.
4 |
5 | - bananapi/bpim2plus.py
6 | - bananapi/bpim2zero.py
7 | - bananapi/bpim5.py
8 | - beagleboard/beaglebone_ai.py
9 | - beagleboard/beaglebone_black.py
10 | - beagleboard/beaglebone_pocketbeagle.py
11 | - beagleboard/beaglev_starlight.py
12 | - binho_nova.py
13 | - clockworkcpi3.py
14 | - coral_dev_board_mini.py
15 | - coral_dev_board.py
16 | - dragonboard_410c.py
17 | - feather_huzzah.py
18 | - feather_u2if.py
19 | - ftdi_ft2232h.py
20 | - ftdi_ft232h.py
21 | - giantboard.py
22 | - greatfet_one.py
23 | - hardkernel/odroidc2.py
24 | - hardkernel/odroidc4.py
25 | - hardkernel/odroidn2.py
26 | - hardkernel/odroidxu4.py
27 | - hifive_unleashed.py
28 | - itsybitsy_u2if.py
29 | - khadas/khadasvim3.py
30 | - librecomputer/aml_s905x_cc_v1.py
31 | - lubancat/lubancat_imx6ull.py
32 | - macropad_u2if.py
33 | - nanopi/duo2.py
34 | - nanopi/neoair.py
35 | - nanopi/neo.py
36 | - nodemcu.py
37 | - nvidia/clara_agx_xavier.py
38 | - nvidia/jetson_nano.py
39 | - nvidia/jetson_nx.py
40 | - nvidia/jetson_orin.py
41 | - nvidia/jetson_tx1.py
42 | - nvidia/jetson_tx2_nx.py
43 | - nvidia/jetson_tx2.py
44 | - nvidia/jetson_xavier.py
45 | - onion/omega2.py
46 | - orangepi/orangepi3.py
47 | - orangepi/orangepi4.py
48 | - orangepi/orangepipc.py
49 | - orangepi/orangepir1.py
50 | - orangepi/orangepizero2.py
51 | - orangepi/orangepizeroplus2h5.py
52 | - orangepi/orangepizeroplus.py
53 | - orangepi/orangepizero.py
54 | - pico_u2if.py
55 | - pine64.py
56 | - pineH64.py
57 | - qtpy_u2if.py
58 | - radxa/radxazero.py
59 | - radxa/rockpi4.py
60 | - radxa/rockpie.py
61 | - radxa/rockpis.py
62 | - raspberrypi/raspi_1b_rev1.py
63 | - raspberrypi/raspi_1b_rev2.py
64 | - raspberrypi/raspi_40pin.py
65 | - raspberrypi/raspi_4b.py
66 | - raspberrypi/raspi_cm.py
67 | - siemens/siemens_iot2050.py
68 | - soPine.py
69 | - stm32/osd32mp1_brk.py
70 | - stm32/osd32mp1_red.py
71 | - stm32/stm32mp157c_dk2.py
72 | - tritium-h3.py
73 |
--------------------------------------------------------------------------------
/Test/test_Profile.py:
--------------------------------------------------------------------------------
1 | from lib.oven import Profile
2 | import os
3 | import json
4 |
5 | def get_profile(file = "test-fast.json"):
6 | profile_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'Test', file))
7 | print(profile_path)
8 | with open(profile_path) as infile:
9 | profile_json = json.dumps(json.load(infile))
10 | profile = Profile(profile_json)
11 |
12 | return profile
13 |
14 |
15 | def test_get_target_temperature():
16 | profile = get_profile()
17 |
18 | temperature = profile.get_target_temperature(3000)
19 | assert int(temperature) == 200
20 |
21 | temperature = profile.get_target_temperature(6004)
22 | assert temperature == 801.0
23 |
24 |
25 | def test_find_time_from_temperature():
26 | profile = get_profile()
27 |
28 | time = profile.find_next_time_from_temperature(500)
29 | assert time == 4800
30 |
31 | time = profile.find_next_time_from_temperature(2004)
32 | assert time == 10857.6
33 |
34 | time = profile.find_next_time_from_temperature(1900)
35 | assert time == 10400.0
36 |
37 |
38 |
39 | def test_find_time_odd_profile():
40 | profile = get_profile("test-cases.json")
41 |
42 | time = profile.find_next_time_from_temperature(500)
43 | assert time == 4200
44 |
45 | time = profile.find_next_time_from_temperature(2023)
46 | assert time == 16676.0
47 |
48 |
49 | def test_find_x_given_y_on_line_from_two_points():
50 | profile = get_profile()
51 |
52 | y = 500
53 | p1 = [3600, 200]
54 | p2 = [10800, 2000]
55 | time = profile.find_x_given_y_on_line_from_two_points(y, p1, p2)
56 |
57 | assert time == 4800
58 |
59 | y = 500
60 | p1 = [3600, 200]
61 | p2 = [10800, 200]
62 | time = profile.find_x_given_y_on_line_from_two_points(y, p1, p2)
63 |
64 | assert time == 0
65 |
66 | y = 500
67 | p1 = [3600, 600]
68 | p2 = [10800, 600]
69 | time = profile.find_x_given_y_on_line_from_two_points(y, p1, p2)
70 |
71 | assert time == 0
72 |
73 | y = 500
74 | p1 = [3600, 500]
75 | p2 = [10800, 500]
76 | time = profile.find_x_given_y_on_line_from_two_points(y, p1, p2)
77 |
78 | assert time == 0
79 |
80 |
81 |
--------------------------------------------------------------------------------
/docs/old-to-new.md:
--------------------------------------------------------------------------------
1 | Migrating from Old to New kiln-controller Code
2 | ==========
3 |
4 | This describes how to migrate from the old version of the code to the new.
5 |
6 | ## History
7 |
8 | In early 2023 I rewrote most of the kiln-controller back-end code. It was a major change with all new classes. Lots of libraries were removed and the Adafruit blinka library was chosen which allows for a hundred or more supported boards in addition to raspberry pis.
9 |
10 | ## Why Swap?
11 |
12 | As of 2023 I stopped supporting and adding features to the old code. It still works, but I no longer use it, update it, test it, or change it.
13 |
14 | ## Easiest possible migration
15 |
16 | The easiest way to convert from the old code to the new is to use software spi, also known as bitbanging, to grab data from the thermocouple board. You will not have to make any wiring changes. You'll only need to change config.py and test it to make sure it works.
17 |
18 | 1. make a backup of config.py. You'll need it for the next step.
19 |
20 | ```
21 | cp config.py config.py.bak
22 | ```
23 |
24 | 2. update to the new code
25 | ```
26 | git checkout master
27 | git pull (maybe force here???)
28 | ```
29 | FIXME - need instructions on branch names to checkout etc.
30 |
31 |
32 | 3. Install all the libraries that the new code uses
33 |
34 | ```
35 | cd kiln-controller
36 | source venv/bin/activate
37 | pip install -r ./requirements.txt
38 | ```
39 |
40 | 4. find these settings in config.py.bak and change them in config.py:
41 |
42 | ```
43 | gpio_sensor_cs = 27
44 | gpio_sensor_clock = 22
45 | gpio_sensor_data = 17
46 | gpio_sensor_di = 10
47 | gpio_heat = 23
48 | ```
49 |
50 | change them in config.py to look like so:
51 |
52 | ```
53 | spi_cs = board.D27
54 | spi_sclk = board.D22
55 | spi_miso = board.D17
56 | spi_mosi = board.D10 #this one is not actually used, so set it or not
57 | gpio_heat = board.D23
58 | gpio_heat_invert = False
59 | ```
60 |
61 | 5. test the thermocouple board and thermocouple
62 |
63 | ```
64 | ./test-thermocouple.py
65 | ```
66 |
67 | You should see that **software spi** is configured. You should see the pin configuration printed out. You should see the temperature reported every second.
68 |
69 | 4. test output
70 |
71 | ```
72 | ./test-output.py
73 | ```
74 |
75 | Every 5 seconds, verify the output is flipped from on to off or vice versa.
76 |
77 |
78 |
--------------------------------------------------------------------------------
/public/state.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Kiln Controller
5 |
6 |
7 |
8 |
9 |
10 |
21 |
22 |
23 |
ERROR
24 |
28 |
32 |
36 |
40 |
41 |
42 |
49 |
50 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/test-thermocouple.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import config
3 | from digitalio import DigitalInOut
4 | import time
5 | import datetime
6 | import busio
7 | import adafruit_bitbangio as bitbangio
8 |
9 | try:
10 | import board
11 | except NotImplementedError:
12 | print("not running a recognized blinka board, exiting...")
13 | import sys
14 | sys.exit()
15 |
16 | ########################################################################
17 | #
18 | # To test your thermocouple...
19 | #
20 | # Edit config.py and set the following in that file to match your
21 | # hardware setup: SPI_SCLK, SPI_MOSI, SPI_MISO, SPI_CS
22 | #
23 | # then run this script...
24 | #
25 | # ./test-thermocouple.py
26 | #
27 | # It will output a temperature in degrees every second. Touch your
28 | # thermocouple to heat it up and make sure the value changes. Accuracy
29 | # of my thermocouple is .25C.
30 | ########################################################################
31 |
32 | spi = None
33 | if(hasattr(config,'spi_sclk') and
34 | hasattr(config,'spi_mosi') and
35 | hasattr(config,'spi_miso')):
36 | spi = bitbangio.SPI(config.spi_sclk, config.spi_mosi, config.spi_miso)
37 | print("Software SPI selected for reading thermocouple")
38 | print("SPI configured as:\n")
39 | print(" config.spi_sclk = %s BCM pin" % (config.spi_sclk))
40 | print(" config.spi_mosi = %s BCM pin" % (config.spi_mosi))
41 | print(" config.spi_miso = %s BCM pin" % (config.spi_miso))
42 | print(" config.spi_cs = %s BCM pin\n" % (config.spi_cs))
43 | else:
44 | spi = board.SPI();
45 | print("Hardware SPI selected for reading thermocouple")
46 |
47 | cs = DigitalInOut(config.spi_cs)
48 | cs.switch_to_output(value=True)
49 | sensor = None
50 |
51 | print("\nboard: %s" % (board.board_id))
52 | if(config.max31855):
53 | import adafruit_max31855
54 | print("thermocouple: adafruit max31855")
55 | sensor = adafruit_max31855.MAX31855(spi, cs)
56 | if(config.max31856):
57 | import adafruit_max31856
58 | print("thermocouple: adafruit max31856")
59 | sensor = adafruit_max31856.MAX31856(spi, cs)
60 |
61 | print("Degrees displayed in %s\n" % (config.temp_scale))
62 |
63 | temp = 0
64 | while(True):
65 | time.sleep(1)
66 | try:
67 | temp = sensor.temperature
68 | scale = "C"
69 | if config.temp_scale == "f":
70 | temp = temp * (9/5) + 32
71 | scale ="F"
72 | print("%s %0.2f%s" %(datetime.datetime.now(),temp,scale))
73 | except Exception as error:
74 | print("error: " , error)
75 |
--------------------------------------------------------------------------------
/watcher.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import requests
3 | import json
4 | import time
5 | import datetime
6 | import logging
7 |
8 | # this monitors your kiln stats every N seconds
9 | # if X checks fail, an alert is sent to a slack channel
10 | # configure an incoming web hook on the slack channel
11 | # set slack_hook_url to that
12 |
13 | logging.basicConfig(level=logging.INFO)
14 | log = logging.getLogger(__name__)
15 |
16 | class Watcher(object):
17 |
18 | def __init__(self,kiln_url,slack_hook_url,bad_check_limit=6,temp_error_limit=10,sleepfor=10):
19 | self.kiln_url = kiln_url
20 | self.slack_hook_url = slack_hook_url
21 | self.bad_check_limit = bad_check_limit
22 | self.temp_error_limit = temp_error_limit
23 | self.sleepfor = sleepfor
24 | self.bad_checks = 0
25 | self.stats = {}
26 |
27 | def get_stats(self):
28 | try:
29 | r = requests.get(self.kiln_url,timeout=1)
30 | return r.json()
31 | except requests.exceptions.Timeout:
32 | log.error("network timeout. check kiln_url and port.")
33 | return {}
34 | except requests.exceptions.ConnectionError:
35 | log.error("network connection error. check kiln_url and port.")
36 | return {}
37 | except:
38 | return {}
39 |
40 | def send_alert(self,msg):
41 | log.error("sending alert: %s" % msg)
42 | try:
43 | r = requests.post(self.slack_hook_url, json={'text': msg })
44 | except:
45 | pass
46 |
47 | def has_errors(self):
48 | if 'time' not in self.stats:
49 | log.error("no data")
50 | return True
51 | if 'err' in self.stats:
52 | if abs(self.stats['err']) > self.temp_error_limit:
53 | log.error("temp out of whack %0.2f" % self.stats['err'])
54 | return True
55 | return False
56 |
57 | def run(self):
58 | log.info("started watching %s" % self.kiln_url)
59 | while(True):
60 | self.stats = self.get_stats()
61 | if self.has_errors():
62 | self.bad_checks = self.bad_checks + 1
63 | else:
64 | try:
65 | log.info("OK temp=%0.2f target=%0.2f error=%0.2f" % (self.stats['ispoint'],self.stats['setpoint'],self.stats['err']))
66 | except:
67 | pass
68 |
69 | if self.bad_checks >= self.bad_check_limit:
70 | msg = "error kiln needs help. %s" % json.dumps(self.stats,indent=2, sort_keys=True)
71 | self.send_alert(msg)
72 | self.bad_checks = 0
73 |
74 | time.sleep(self.sleepfor)
75 |
76 | if __name__ == "__main__":
77 |
78 | watcher = Watcher(
79 | kiln_url = "http://192.168.1.84:8081/api/stats",
80 | slack_hook_url = "you must add this",
81 | bad_check_limit = 6,
82 | temp_error_limit = 10,
83 | sleepfor = 10 )
84 |
85 | watcher.run()
86 |
--------------------------------------------------------------------------------
/kiln-logger.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import websocket
4 | import json
5 | import time
6 | import csv
7 | import argparse
8 | import sys
9 |
10 |
11 | STD_HEADER = [
12 | 'stamp',
13 | 'runtime',
14 | 'temperature',
15 | 'target',
16 | 'state',
17 | 'heat',
18 | 'totaltime',
19 | 'profile',
20 | ]
21 |
22 |
23 | PID_HEADER = [
24 | 'pid_time',
25 | 'pid_timeDelta',
26 | 'pid_setpoint',
27 | 'pid_ispoint',
28 | 'pid_err',
29 | 'pid_errDelta',
30 | 'pid_p',
31 | 'pid_i',
32 | 'pid_d',
33 | 'pid_kp',
34 | 'pid_ki',
35 | 'pid_kd',
36 | 'pid_pid',
37 | 'pid_out',
38 | ]
39 |
40 |
41 | def logger(hostname, csvfile, noprofilestats, pidstats, stdout):
42 | status_ws = websocket.WebSocket()
43 |
44 | csv_fields = []
45 | if not noprofilestats:
46 | csv_fields += STD_HEADER
47 | if pidstats:
48 | csv_fields += PID_HEADER
49 |
50 | out = open(csvfile, 'w')
51 | csv_out = csv.DictWriter(out, csv_fields, extrasaction='ignore')
52 | csv_out.writeheader()
53 |
54 | if stdout:
55 | csv_stdout = csv.DictWriter(sys.stdout, csv_fields, extrasaction='ignore', delimiter='\t')
56 | csv_stdout.writeheader()
57 | else:
58 | csv_stdout = None
59 |
60 | while True:
61 | try:
62 | msg = json.loads(status_ws.recv())
63 |
64 | except websocket.WebSocketException:
65 | try:
66 | status_ws.connect(f'ws://{hostname}/status')
67 | except Exception:
68 | time.sleep(5)
69 |
70 | continue
71 |
72 | if msg.get('type') == 'backlog':
73 | continue
74 |
75 | if not noprofilestats:
76 | msg['stamp'] = time.time()
77 | if pidstats and 'pidstats' in msg:
78 | for k, v in msg.get('pidstats', {}).items():
79 | msg[f"pid_{k}"] = v
80 |
81 | csv_out.writerow(msg)
82 | out.flush()
83 |
84 | if stdout:
85 | for k in list(msg.keys()):
86 | v = msg[k]
87 | if isinstance(v, float):
88 | msg[k] = '{:5.3f}'.format(v)
89 | csv_stdout.writerow(msg)
90 | sys.stdout.flush()
91 |
92 |
93 | if __name__ == "__main__":
94 | parser = argparse.ArgumentParser(description='Log kiln data for analysis.')
95 | parser.add_argument('--hostname', type=str, default="localhost:8081", help="The kiln-controller hostname:port")
96 | parser.add_argument('--csvfile', type=str, default="/tmp/kilnstats.csv", help="Where to write the kiln stats to")
97 | parser.add_argument('--pidstats', action='store_true', help="Include PID stats")
98 | parser.add_argument('--noprofilestats', action='store_true', help="Do not store profile stats (default is to store them)")
99 | parser.add_argument('--stdout', action='store_true', help="Also print to stdout")
100 | args = parser.parse_args()
101 |
102 | logger(args.hostname, args.csvfile, args.noprofilestats, args.pidstats, args.stdout)
103 |
--------------------------------------------------------------------------------
/public/assets/css/select2-bootstrap.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Select2 Bootstrap CSS 1.0
3 | * Compatible with select2 3.3.2 and bootstrap 2.3.1
4 | * MIT License
5 | */
6 | .select2-container {
7 | vertical-align: middle;
8 | }
9 | .select2-container.input-mini {
10 | width: 60px;
11 | }
12 | .select2-container.input-small {
13 | width: 90px;
14 | }
15 | .select2-container.input-medium {
16 | width: 150px;
17 | }
18 | .select2-container.input-large {
19 | width: 210px;
20 | }
21 | .select2-container.input-xlarge {
22 | width: 270px;
23 | }
24 | .select2-container.input-xxlarge {
25 | width: 530px;
26 | }
27 | .select2-container.input-default {
28 | width: 220px;
29 | }
30 | .select2-container[class*="span"] {
31 | float: none;
32 | margin-left: 0;
33 | }
34 |
35 | .select2-container .select2-choice,
36 | .select2-container-multi .select2-choices {
37 | height: 28px;
38 | line-height: 29px;
39 | border: 1px solid #cccccc;
40 | -webkit-border-radius: 4px;
41 | -moz-border-radius: 4px;
42 | border-radius: 4px;
43 | background: none;
44 | background-color: white;
45 | filter: none;
46 | -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
47 | -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
48 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
49 | }
50 |
51 | .select2-container .select2-choice div,
52 | .select2-container.select2-container-disabled .select2-choice div {
53 | border-left: none;
54 | background: none;
55 | filter: none;
56 | }
57 |
58 | .control-group.error [class^="select2-choice"] {
59 | border-color: #b94a48;
60 | }
61 |
62 | .select2-container-multi .select2-choices .select2-search-field {
63 | height: 28px;
64 | line-height: 27px;
65 | }
66 |
67 | .select2-container-active .select2-choice,
68 | .select2-container-multi.select2-container-active .select2-choices {
69 | border-color: rgba(82, 168, 236, 0.8);
70 | outline: none;
71 | -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
72 | -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
73 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
74 | }
75 |
76 | [class^="input-"] .select2-container {
77 | font-size: 14px;
78 | }
79 |
80 | .input-prepend [class^="select2-choice"] {
81 | border-top-left-radius: 0;
82 | border-bottom-left-radius: 0;
83 | }
84 |
85 | .input-append [class^="select2-choice"] {
86 | border-top-right-radius: 0;
87 | border-bottom-right-radius: 0;
88 | }
89 |
90 | .select2-dropdown-open [class^="select2-choice"] {
91 | border-bottom-left-radius: 0;
92 | border-bottom-right-radius: 0;
93 | }
94 |
95 | .select2-dropdown-open.select2-drop-above [class^="select2-choice"] {
96 | border-top-left-radius: 0;
97 | border-top-right-radius: 0;
98 | }
99 |
100 | [class^="input-"] .select2-offscreen {
101 | position: absolute;
102 | }
103 |
104 | /**
105 | * This stops the quick flash when a native selectbox is shown and
106 | * then replaced by a select2 input when javascript kicks in. This can be
107 | * removed if javascript is not present
108 | */
109 | select.select2 {
110 | height: 28px;
111 | visibility: hidden;
112 | }
113 |
--------------------------------------------------------------------------------
/public/assets/js/jquery.flot.resize.js:
--------------------------------------------------------------------------------
1 | /* Flot plugin for automatically redrawing plots as the placeholder resizes.
2 |
3 | Copyright (c) 2007-2013 IOLA and Ole Laursen.
4 | Licensed under the MIT license.
5 |
6 | It works by listening for changes on the placeholder div (through the jQuery
7 | resize event plugin) - if the size changes, it will redraw the plot.
8 |
9 | There are no options. If you need to disable the plugin for some plots, you
10 | can just fix the size of their placeholders.
11 |
12 | */
13 |
14 | /* Inline dependency:
15 | * jQuery resize event - v1.1 - 3/14/2010
16 | * http://benalman.com/projects/jquery-resize-plugin/
17 | *
18 | * Copyright (c) 2010 "Cowboy" Ben Alman
19 | * Dual licensed under the MIT and GPL licenses.
20 | * http://benalman.com/about/license/
21 | */
22 |
23 | (function($,t,n){function p(){for(var n=r.length-1;n>=0;n--){var o=$(r[n]);if(o[0]==t||o.is(":visible")){var h=o.width(),d=o.height(),v=o.data(a);!v||h===v.w&&d===v.h?i[f]=i[l]:(i[f]=i[c],o.trigger(u,[v.w=h,v.h=d]))}else v=o.data(a),v.w=0,v.h=0}s!==null&&(s=t.requestAnimationFrame(p))}var r=[],i=$.resize=$.extend($.resize,{}),s,o="setTimeout",u="resize",a=u+"-special-event",f="delay",l="pendingDelay",c="activeDelay",h="throttleWindow";i[l]=250,i[c]=20,i[f]=i[l],i[h]=!0,$.event.special[u]={setup:function(){if(!i[h]&&this[o])return!1;var t=$(this);r.push(this),t.data(a,{w:t.width(),h:t.height()}),r.length===1&&(s=n,p())},teardown:function(){if(!i[h]&&this[o])return!1;var t=$(this);for(var n=r.length-1;n>=0;n--)if(r[n]==this){r.splice(n,1);break}t.removeData(a),r.length||(cancelAnimationFrame(s),s=null)},add:function(t){function s(t,i,s){var o=$(this),u=o.data(a);u.w=i!==n?i:o.width(),u.h=s!==n?s:o.height(),r.apply(this,arguments)}if(!i[h]&&this[o])return!1;var r;if($.isFunction(t))return r=t,s;r=t.handler,t.handler=s}},t.requestAnimationFrame||(t.requestAnimationFrame=function(){return t.webkitRequestAnimationFrame||t.mozRequestAnimationFrame||t.oRequestAnimationFrame||t.msRequestAnimationFrame||function(e,n){return t.setTimeout(e,i[f])}}()),t.cancelAnimationFrame||(t.cancelAnimationFrame=function(){return t.webkitCancelRequestAnimationFrame||t.mozCancelRequestAnimationFrame||t.oCancelRequestAnimationFrame||t.msCancelRequestAnimationFrame||clearTimeout}())})(jQuery,this);
24 |
25 | (function ($) {
26 | var options = { }; // no options
27 |
28 | function init(plot) {
29 | function onResize() {
30 | var placeholder = plot.getPlaceholder();
31 |
32 | // somebody might have hidden us and we can't plot
33 | // when we don't have the dimensions
34 | if (placeholder.width() == 0 || placeholder.height() == 0)
35 | return;
36 |
37 | plot.resize();
38 | plot.setupGrid();
39 | plot.draw();
40 | }
41 |
42 | function bindEvents(plot, eventHolder) {
43 | plot.getPlaceholder().resize(onResize);
44 | }
45 |
46 | function shutdown(plot, eventHolder) {
47 | plot.getPlaceholder().unbind("resize", onResize);
48 | }
49 |
50 | plot.hooks.bindEvents.push(bindEvents);
51 | plot.hooks.shutdown.push(shutdown);
52 | }
53 |
54 | $.plot.plugins.push({
55 | init: init,
56 | options: options,
57 | name: 'resize',
58 | version: '1.0'
59 | });
60 | })(jQuery);
61 |
--------------------------------------------------------------------------------
/docs/schedule.md:
--------------------------------------------------------------------------------
1 | Scheduling a Kiln Run
2 | =====================
3 |
4 | Our lives are busy. Sometimes you'll want your kiln to start at a scheduled time. This is really easy to do with the **at** command. Scheduled events persist if the raspberry pi reboots.
5 |
6 | ## Install the scheduler
7 |
8 | This installs and starts the **at** scheduler.
9 |
10 | sudo apt-get update
11 | sudo apt-get install at
12 |
13 | ### Verify Time Settings
14 |
15 | Verify the date and time and time zone are right on your system:
16 |
17 | date
18 |
19 | If yours looks right, proceed to **Examples**. If not, you need to execute commands to set it. On a raspberry-pi, this is easiest by running...
20 |
21 | sudo raspi-config
22 |
23 | Localisation Options -> Timezone -> Pick one -> Ok
24 |
25 |
26 | ## Examples
27 |
28 | Start a biscuit firing at 5am Friday morning:
29 |
30 | at 5:00am friday <
~/kiln-stats/input/daemon.log; ~/kiln-stats/scripts/go; cd ~/kiln-stats/output; python3 -m http.server
64 | END
65 |
66 | List scheduled jobs...
67 |
68 | atq
69 |
70 | Remove scheduled jobs...
71 |
72 | atrm jobid
73 |
74 | where jobid is an integer that came from the atq output
75 |
--------------------------------------------------------------------------------
/lib/ovenWatcher.py:
--------------------------------------------------------------------------------
1 | import threading,logging,json,time,datetime
2 | from oven import Oven
3 | log = logging.getLogger(__name__)
4 |
5 | class OvenWatcher(threading.Thread):
6 | def __init__(self,oven):
7 | self.last_profile = None
8 | self.last_log = []
9 | self.started = None
10 | self.recording = False
11 | self.observers = []
12 | threading.Thread.__init__(self)
13 | self.daemon = True
14 | self.oven = oven
15 | self.start()
16 |
17 | # FIXME - need to save runs of schedules in near-real-time
18 | # FIXME - this will enable re-start in case of power outage
19 | # FIXME - re-start also requires safety start (pausing at the beginning
20 | # until a temp is reached)
21 | # FIXME - re-start requires a time setting in minutes. if power has been
22 | # out more than N minutes, don't restart
23 | # FIXME - this should not be done in the Watcher, but in the Oven class
24 |
25 | def run(self):
26 | while True:
27 | oven_state = self.oven.get_state()
28 |
29 | # record state for any new clients that join
30 | if oven_state.get("state") == "RUNNING":
31 | self.last_log.append(oven_state)
32 | else:
33 | self.recording = False
34 | self.notify_all(oven_state)
35 | time.sleep(self.oven.time_step)
36 |
37 | def lastlog_subset(self,maxpts=50):
38 | '''send about maxpts from lastlog by skipping unwanted data'''
39 | totalpts = len(self.last_log)
40 | if (totalpts <= maxpts):
41 | return self.last_log
42 | every_nth = int(totalpts / (maxpts - 1))
43 | return self.last_log[::every_nth]
44 |
45 | def record(self, profile):
46 | self.last_profile = profile
47 | self.last_log = []
48 | self.started = datetime.datetime.now()
49 | self.recording = True
50 | #we just turned on, add first state for nice graph
51 | self.last_log.append(self.oven.get_state())
52 |
53 | def add_observer(self,observer):
54 | if self.last_profile:
55 | p = {
56 | "name": self.last_profile.name,
57 | "data": self.last_profile.data,
58 | "type" : "profile"
59 | }
60 | else:
61 | p = None
62 |
63 | backlog = {
64 | 'type': "backlog",
65 | 'profile': p,
66 | 'log': self.lastlog_subset(),
67 | #'started': self.started
68 | }
69 | print(backlog)
70 | backlog_json = json.dumps(backlog)
71 | try:
72 | print(backlog_json)
73 | observer.send(backlog_json)
74 | except:
75 | log.error("Could not send backlog to new observer")
76 |
77 | self.observers.append(observer)
78 |
79 | def notify_all(self,message):
80 | message_json = json.dumps(message)
81 | log.debug("sending to %d clients: %s"%(len(self.observers),message_json))
82 |
83 | for wsock in self.observers:
84 | if wsock:
85 | try:
86 | wsock.send(message_json)
87 | except:
88 | log.error("could not write to socket %s"%wsock)
89 | self.observers.remove(wsock)
90 | else:
91 | self.observers.remove(wsock)
92 |
--------------------------------------------------------------------------------
/docs/pid_tuning.md:
--------------------------------------------------------------------------------
1 | Tuning PID Values
2 | =================
3 |
4 | This animation is worth a thousand words...
5 |
6 | 
7 |
8 | ## The Goal
9 | A controller with properly tuned PID values reacts quickly to changes in the set point, but does not overshoot much. It settles quickly from any oscillations and hovers really close to the set point. What do I mean by close? The average error for my kiln on a 13 hour schedule is .75 degrees F... and I have a noisy thermocouple, so it is possible to do even better.
10 |
11 | ## The Tuning Process
12 |
13 | ### Automatic Tuning
14 |
15 | Contributor [ADQ](https://github.com/adq) worked hard on creating a [Ziegler Nicols auto-tuner](ziegler_tuning.md) which is python script that heats your kiln, saves data to a csv, and then gives you PID parameters for config.py.
16 |
17 | ### Manual Tuning
18 |
19 | Even if you used the tuner above, it's likely you'll need to do some manual tuning. Let's start with some reasonable values for PID settings in config.py...
20 |
21 | pid_kp = 20
22 | pid_ki = 50
23 | pid_kd = 100
24 |
25 | When you change values, change only one at a time and watch the impact. Change values by either doubling or halving.
26 |
27 | Run a test schedule. I used a schedule that switches between 200 and 250 F every 30 minutes. The kiln will likely shoot past 200. This is normal. We'll eventually get rid of most of the overshoot, but probably not all.
28 |
29 | Let's balance pid_ki first (the integral). The lower the pid_ki, the greater the impact it will have on the system. If a system is consistently low or high, the integral is used to help bring the system closer to the set point. The integral accumulates over time and has [potentially] a bigger and bigger impact.
30 |
31 | * If you have a steady state (no oscillations), but the temperature is always above the set point, increase pid_ki.
32 | * If you have a steady state (no oscillations), but the temperature is always below the set point, decrease pid_ki.
33 | * If you have an oscillation but the temperature is mostly above the setpoint, increase pid_ki.
34 | * If you have an oscillation but the temperature is mostly below the setpoint, decrease pid_ki.
35 |
36 | Let's set pid_kp next (proportional). Think of pid_kp as a dimmable light switch that turns on the heat when below the set point and turns it off when above. The brightness of the dimmable light is defined by pid_kp. Be careful reducing pid_kp too much. It can result in strange behavior.
37 |
38 | * If you have oscillations that don't stop or increase in size, reduce pid_kp
39 | * If you have too much overshoot (after adjusting pid_kd), reduce pid_kp
40 | * If you approach the set point wayyy tooo sloooowly, increase pid_kp
41 |
42 | Now set pid_kd (derivative). pid_kd makes an impact when there is a change in temperature. It's used to reduce oscillations.
43 |
44 | * If you have oscillations that take too long to settle, increase pid_kd
45 | * If you have crazy, unpredictable behavior from the controller, reduce pid_kd
46 |
47 | Expect some overshoot as the kiln reaches the set temperature the first time, but no oscillation. Any holds or ramps after that should have a smooth transition and should remain really close to the set point [1 or 2 degrees F].
48 |
49 | ## Troubleshooting
50 |
51 | * only change one value at a time, then test it.
52 | * change values by doubling or halving
53 |
--------------------------------------------------------------------------------
/docs/ziegler_tuning.md:
--------------------------------------------------------------------------------
1 | # PID Tuning Using Ziegler-Nicols
2 |
3 | This uses the Ziegler Nicols method to estimate values for the Kp/Ki/Kd PID control values.
4 |
5 | The method implemented here is taken from ["Ziegler–Nichols Tuning Method"](https://www.ias.ac.in/article/fulltext/reso/025/10/1385-1397) by Vishakha Vijay Patel
6 |
7 | One issue with Ziegler Nicols is that is a **heuristic**: it generally works quite well, but it might not be the optimal values. Further manual adjustment may be necessary.
8 |
9 | - make sure the kiln-controller is **stopped**
10 | - make sure your kiln is in the same state it will be in during a normal firing. For instance, if you use a kiln vent during normal firing, make sure it is on.
11 | - make sure the kiln is completely cool. We need to record the data starting from room temperature to correctly measure the effect of kiln/heating.
12 |
13 | ## Step 1: Stop the kiln-controller process
14 |
15 | If the kiln controller auto-starts, you'll need to stop it before tuning...
16 |
17 | ```sudo service kiln-controller stop```
18 |
19 | After, you're done with the tuning process, just reboot and the kiln-controller will automatically restart.
20 |
21 | ## Step 2: Run the Auto-Tuner
22 |
23 | run the auto-tuner:
24 | ```
25 | source venv/bin/activate; ./kiln-tuner.py
26 | ```
27 |
28 | The kiln-tuner will heat your kiln to 400F. Next it will start cooling. Once the temperature goes back to 400F, the PID values are calculated and the program ends. The output will look like this:
29 |
30 | ```
31 | stage = cooling, actual = 401.51, target = 400.00
32 | stage = cooling, actual = 401.26, target = 400.00
33 | stage = cooling, actual = 401.01, target = 400.00
34 | stage = cooling, actual = 400.77, target = 400.00
35 | stage = cooling, actual = 400.52, target = 400.00
36 | stage = cooling, actual = 400.28, target = 400.00
37 | stage = cooling, actual = 400.03, target = 400.00
38 | stage = cooling, actual = 399.78, target = 400.00
39 | pid_kp = 14.231158917317776
40 | pid_ki = 4.745613033146341
41 | pid_kd = 240.27736881914797
42 | ```
43 |
44 | ## Step 3: Replace the PID parameters in config.py
45 |
46 | Copy & paste the pid_kp, pid_ki, and pid_kd values into config.py and restart the kiln-controller. Test out the values by firing your kiln. They may require manual adjustment.
47 |
48 | ## The values didn't work for me.
49 |
50 | The Ziegler Nicols estimate requires that your graph look similar to this: [kiln-tuner-example.png](kiln-tuner-example.png). The smooth linear part of the chart is very important. If it is too short, try increasing the target temperature (see later). The red diagonal line **must** follow the smooth part of your chart closely.
51 |
52 | ## My diagonal line isn't right
53 |
54 | You might need to adjust the line parameters to make it fit your data properly. You'll do this using previously saved data without the need to heat & cool again.
55 |
56 | ```
57 | source venv/bin/activate;./kiln-tuner.py -c -s -d 4
58 | ```
59 |
60 | | Parameter | Description |
61 | | --------- | ----------- |
62 | | -c | calculate only (don't heat/cool and record) |
63 | | -s | show plot (requires pyplot be installed in the virtual env) |
64 | | -d float | tangent divisor which modifies which part of the profile is used to calculate the line. Must be >= 2.0. Vary it to get a better fit. |
65 |
66 | ## Changing the target temperature
67 |
68 | By default it is 400F. You can change this as follows:
69 |
70 | ```
71 | python kiln-tuner.py -t 500
72 | ```
73 |
--------------------------------------------------------------------------------
/docs/troubleshooting.md:
--------------------------------------------------------------------------------
1 | Troubleshooting
2 | ==========
3 |
4 | When I started this project, I'd never worked with RPi gpio. I think I got
5 | just about everything backwards possible. I blew up a MAX-31855 chip... POOF,
6 | up in smoke!
7 |
8 | So, invest a little time to learn the hardware and the software available to
9 | you to verify everything works as expected.
10 |
11 | ## Breadboard Orientation
12 |
13 | 
14 |
15 | If you're using a breadboard with a labeled break-out board, verify:
16 |
17 | * where pin one is using a multimeter. it sounds stupid, but it will save you time.
18 | * measure the voltage between all the 3V3 pins and a GND pin
19 | * measure the voltage between all the GND pins and a GND pin
20 | * measure the voltage between the 5V pins and a GND pin
21 |
22 | ## Test Each GPIO Pin
23 |
24 | I thought at one point that I had fried my RPi. I needed to verify that it
25 | still worked as expected. Here's what I did to verify GPIO on my pi.
26 |
27 | ```source venv/bin/activate; ./gpioreadall.py```
28 |
29 | and you'll get output that looks something like this...
30 |
31 | ```
32 | +-----+-----+---------+------+---+---Pi 3---+---+------+---------+-----+-----+
33 | | BCM | wPi | Name | Mode | V | Physical | V | Mode | Name | wPi | BCM |
34 | +-----+-----+---------+------+---+----++----+---+------+---------+-----+-----+
35 | | | | 3.3v | | | 1 || 2 | | | 5v | | |
36 | | 2 | 8 | SDA.1 | IN | 1 | 3 || 4 | | | 5v | | |
37 | | 3 | 9 | SCL.1 | IN | 1 | 5 || 6 | | | 0v | | |
38 | | 4 | 7 | GPIO. 7 | IN | 0 | 7 || 8 | 0 | IN | TxD | 15 | 14 |
39 | | | | 0v | | | 9 || 10 | 1 | IN | RxD | 16 | 15 |
40 | | 17 | 0 | GPIO. 0 | IN | 0 | 11 || 12 | 1 | IN | GPIO. 1 | 1 | 18 |
41 | | 27 | 2 | GPIO. 2 | IN | 0 | 13 || 14 | | | 0v | | |
42 | | 22 | 3 | GPIO. 3 | IN | 0 | 15 || 16 | 0 | IN | GPIO. 4 | 4 | 23 |
43 | | | | 3.3v | | | 17 || 18 | 0 | IN | GPIO. 5 | 5 | 24 |
44 | | 10 | 12 | MOSI | IN | 0 | 19 || 20 | | | 0v | | |
45 | | 9 | 13 | MISO | IN | 0 | 21 || 22 | 0 | IN | GPIO. 6 | 6 | 25 |
46 | | 11 | 14 | SCLK | IN | 0 | 23 || 24 | 1 | IN | CE0 | 10 | 8 |
47 | | | | 0v | | | 25 || 26 | 1 | IN | CE1 | 11 | 7 |
48 | | 0 | 30 | SDA.0 | IN | 0 | 27 || 28 | 1 | IN | SCL.0 | 31 | 1 |
49 | | 5 | 21 | GPIO.21 | IN | 0 | 29 || 30 | | | 0v | | |
50 | | 6 | 22 | GPIO.22 | IN | 0 | 31 || 32 | 0 | IN | GPIO.26 | 26 | 12 |
51 | | 13 | 23 | GPIO.23 | IN | 0 | 33 || 34 | | | 0v | | |
52 | | 19 | 24 | GPIO.24 | IN | 0 | 35 || 36 | 0 | IN | GPIO.27 | 27 | 16 |
53 | | 26 | 25 | GPIO.25 | IN | 0 | 37 || 38 | 0 | IN | GPIO.28 | 28 | 20 |
54 | | | | 0v | | | 39 || 40 | 0 | IN | GPIO.29 | 29 | 21 |
55 | +-----+-----+---------+------+---+----++----+---+------+---------+-----+-----+
56 | | BCM | wPi | Name | Mode | V | Physical | V | Mode | Name | wPi | BCM |
57 | +-----+-----+---------+------+---+---Pi 3---+---+------+---------+-----+-----+
58 | ```
59 |
60 | make sure all the GPIO pins you want to test have a **Mode** of **IN** to make it in input
61 | if not, set the mode for each..
62 |
63 | so, for example, to set **BCM** pin 4 as an input
64 |
65 | ```gpio -g mode 4 input```
66 |
67 | verify it got set correctly using
68 |
69 | ```gpio readall```
70 |
71 | enable pull-down resistor for pin 4 to make sure **V** stays zero when nothing is connected to the input
72 |
73 | ```gpio -g mode 4 down```
74 |
75 | This will show you the output of gpio readall every 2 seconds. This way you can concentrate on
76 | moving a wire to each gpio pin and then look up to verify **V** has changed as you expect without
77 | having to type.
78 |
79 | ```watch ./gpioreadall.py```
80 |
81 | * connect a 3V3 pin in series to a 1k ohm resistor
82 | * connect the other end of the resistor to each gpio pin one at a time
83 | * when it is connected V should be 1
84 | * when it is disconnected V should be 0
85 |
--------------------------------------------------------------------------------
/gpioreadall.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | # 2021-04-02
3 | # 2021-04-13 Fix Wrong model for Old Style revision codes
4 | # 2021-12-20 Improve Old Style revision codes; ignore unwanted status bits
5 | # 2022-03-25 Zero 2 W
6 | # 2022-04-07 typo
7 | """
8 | Read all GPIO
9 | This version for raspi-gpio debug tool
10 | """
11 | import sys, os, time
12 | import subprocess
13 |
14 | MODES=["IN", "OUT", "ALT5", "ALT4", "ALT0", "ALT1", "ALT2", "ALT3"]
15 | HEADER = ('3.3v', '5v', 2, '5v', 3, 'GND', 4, 14, 'GND', 15, 17, 18, 27, 'GND', 22, 23, '3.3v', 24, 10, 'GND', 9, 25, 11, 8, 'GND', 7, 0, 1, 5, 'GND', 6, 12, 13, 'GND', 19, 16, 26, 20, 'GND', 21)
16 |
17 | # https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#new-style-revision-codes
18 | PiModel = {
19 | 0: 'A',
20 | 1: 'B',
21 | 2: 'A+',
22 | 3: 'B+',
23 | 4: '2B',
24 | 6: 'CM1',
25 | 8: '3B',
26 | 9: 'Zero',
27 | 0xa: 'CM3',
28 | 0xc: 'ZeroW',
29 | 0xd: '3B+',
30 | 0xe: '3A+',
31 | 0x10: 'CM3+',
32 | 0x11: '4B',
33 | 0x12: 'Zero2W',
34 | 0x13: '400',
35 | 0x14: 'CM4'
36 | }
37 |
38 | RED = '\033[1;31m'
39 | GREEN = '\033[1;32m'
40 | ORANGE = '\033[1;33m'
41 | BLUE = '\033[1;34m'
42 | LRED = '\033[1;91m'
43 | YELLOW = '\033[1;93m'
44 | RESET = '\033[0;0m'
45 | COL = {
46 | '3.3v': LRED,
47 | '5v': RED,
48 | 'GND': GREEN
49 | }
50 |
51 | TYPE = 0
52 | rev = 0
53 |
54 | def pin_state(g):
55 | """
56 | Return "state" of BCM g
57 | Return is tuple (name, mode, value)
58 | """
59 | result = subprocess.run(['raspi-gpio', 'get', ascii(g)], stdout=subprocess.PIPE).stdout.decode('utf-8')
60 |
61 | D = {} # Convert output of raspi-gpio get to dict for convenience
62 | paras = result.split()
63 | for par in paras[2:] :
64 | p, v = par.split('=')
65 | if (v.isdigit()):
66 | D[p] = int(v)
67 | else:
68 | D[p] = v
69 |
70 | if('fsel' in D):
71 | if(D['fsel'] < 2): # i.e. IN or OUT
72 | name = 'GPIO{}'.format(g)
73 | else:
74 | name = D['func']
75 |
76 | mode = MODES[D['fsel']]
77 | if(D['fsel'] == 0 and 'pull' in D):
78 | if(D['pull'] == 'UP'):
79 | mode = 'IN ^'
80 | if(D['pull'] == 'DOWN'):
81 | mode = 'IN v'
82 | else:
83 | name = D['func']
84 | mode = ''
85 |
86 | return name, mode, D['level']
87 |
88 | def print_gpio(pin_state):
89 | """
90 | Print listing of Raspberry pins, state & value
91 | Layout matching Pi 2 row Header
92 | """
93 | global TYPE, rev
94 | GPIOPINS = 40
95 | try:
96 | Model = 'Pi ' + PiModel[TYPE]
97 | except:
98 | Model = 'Pi ??'
99 | if rev < 16 : # older models (pre PiB+)
100 | GPIOPINS = 26
101 |
102 | print('+-----+------------+------+---+{:^10}+---+------+-----------+-----+'.format(Model) )
103 | print('| BCM | Name | Mode | V | Board | V | Mode | Name | BCM |')
104 | print('+-----+------------+------+---+----++----+---+------+-----------+-----+')
105 |
106 | for h in range(1, GPIOPINS, 2):
107 | # odd pin
108 | hh = HEADER[h-1]
109 | if(type(hh)==type(1)):
110 | print('|{0:4} | {1[0]:<10} | {1[1]:<4} | {1[2]} |{2:3} '.format(hh, pin_state(hh), h), end='|| ')
111 | else:
112 | # print('| {:18} | {:2}'.format(hh, h), end=' || ') # non-coloured output
113 | print('| {}{:18} | {:2}{}'.format(COL[hh], hh, h, RESET), end=' || ') # coloured output
114 | # even pin
115 | hh = HEADER[h]
116 | if(type(hh)==type(1)):
117 | print('{0:2} | {1[2]:<2}| {1[1]:<5}| {1[0]:<10}|{2:4} |'.format(h+1, pin_state(hh), hh))
118 | else:
119 | # print('{:2} | {:9} |'.format(h+1, hh)) # non-coloured output
120 | print('{}{:2} | {:9}{} |'.format(COL[hh], h+1, hh, RESET)) # coloured output
121 | print('+-----+------------+------+---+----++----+---+------+-----------+-----+')
122 | print('| BCM | Name | Mode | V | Board | V | Mode | Name | BCM |')
123 | print('+-----+------------+------+---+{:^10}+---+------+-----------+-----+'.format(Model) )
124 |
125 | def get_hardware_revision():
126 | """
127 | Returns the Pi's hardware revision number.
128 | """
129 | with open('/proc/cpuinfo', 'r') as f:
130 | for line in f.readlines():
131 | if 'Revision' in line:
132 | REV = line.split(':')[1]
133 | REV = REV.strip() # Revision as string
134 | return int(REV, base=16)
135 |
136 | def main():
137 | global TYPE, rev
138 | rev = get_hardware_revision()
139 |
140 | if(rev & 0x800000): # New Style
141 | TYPE = (rev&0x00000FF0)>>4
142 | else: # Old Style
143 | rev &= 0x1F
144 | MM = [0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 3, 6, 2, 3, 6, 2]
145 | TYPE = MM[rev] # Map Old Style revision to TYPE
146 |
147 | print_gpio(pin_state)
148 |
149 | if __name__ == '__main__':
150 | main()
151 |
152 |
--------------------------------------------------------------------------------
/public/assets/js/jquery.flot.draggable.js:
--------------------------------------------------------------------------------
1 | /* Flot plugin for adding point dragging capabilities to a plot.
2 | Author: Zach Dwiel - Heavy inspiration from Chris Leonello. Thank you!
3 |
4 | // dependencies: jquery.event.drag.js */
5 |
6 | (function ($) {
7 | var options = {
8 | xaxis: {
9 | draggable: false,
10 | }, yaxis: {
11 | draggable: false,
12 | }, grid: {
13 | draggable: false,
14 | }
15 | },
16 | drag = { pos: { x:null, y:null}, active: false };
17 |
18 | function init(plot) {
19 | function bindEvents(plot, eventHolder) {
20 | var o = plot.getOptions();
21 | var i;
22 | var series_draggable = false;
23 | var series = plot.getData();
24 | for (i = 0; i < series.length; ++i) {
25 | if(series[i].draggable || series[i].draggablex || series[i].draggabley) {
26 | series_draggable = true;
27 | }
28 | }
29 | if (o.grid.draggable || o.xaxis.draggable || o.yaxis.draggable || series_draggable) {
30 | eventHolder.bind("dragstart", { distance: 10 }, function (e) {
31 | if (e.which != 1) // only accept left-click
32 | return false;
33 | var plotOffset = plot.getPlotOffset();
34 | var offset = eventHolder.offset(),
35 | pos = { pageX: e.pageX, pageY: e.pageY },
36 | canvasX = e.pageX - offset.left - plotOffset.left,
37 | canvasY = e.pageY - offset.top - plotOffset.top;
38 | drag.gridOffset = {top: offset.top + plotOffset.top, left: offset.left + plotOffset.left};
39 |
40 | drag.item = plot.findNearbyItem(canvasX, canvasY, function (s) { return s["draggable"] != false; });
41 |
42 | if (drag.item) {
43 | drag.item.pageX = parseInt(drag.item.series.xaxis.p2c(drag.item.datapoint[0]) + offset.left + plotOffset.left);
44 | drag.item.pageY = parseInt(drag.item.series.yaxis.p2c(drag.item.datapoint[1]) + offset.top + plotOffset.top);
45 | drag.active = true;
46 | }
47 | });
48 | eventHolder.bind("drag", function (pos) {
49 | var axes = plot.getAxes();
50 | var ax = axes.xaxis;
51 | var ay = axes.yaxis;
52 | var ax2 = axes.x2axis;
53 | var ay2 = axes.y2axis;
54 | var sidx = drag.item.seriesIndex;
55 | var didx = drag.item.dataIndex;
56 | var s = plot.getData()[sidx];
57 |
58 | if (drag.item.series.yaxis == ay2)
59 | ay = ay2;
60 | if (drag.item.series.xaxis == ax2)
61 | ax = ax2;
62 |
63 | // Bring down to int
64 | var newx = Math.floor(ax.min + (pos.pageX-drag.gridOffset.left)/ax.scale);
65 | var newy = Math.floor(ay.max - (pos.pageY-drag.gridOffset.top)/ay.scale);
66 |
67 | series[sidx].data[didx] = [newx, newy];
68 | plot.processData();
69 |
70 | // change the raw data instead of processing every point all over again, not as clean, but faster
71 | var points = s.datapoints.points;
72 | var ps = s.datapoints.pointsize;
73 | if((o.grid.draggable || o.xaxis.draggable || s.draggablex || s.draggable) && (s.draggablex != false)) {
74 | points[didx*ps] = newx;
75 | }
76 | if((o.grid.draggable || o.yaxis.draggable || s.draggabley || s.draggable) && (s.draggabley != false)) {
77 | points[didx*ps+1] = newy;
78 | }
79 |
80 | var is_last = series[sidx].data.length == didx+1;
81 |
82 | // funny hack to make drag resizing usable
83 | if (newx > ax.max)
84 | {
85 | graph.plot = $.plot("#graph_container", [ graph.profile, graph.live ] , getOptions());
86 | }
87 | else if (newx < (ax.max*0.5) && newx >= ax.datamax && is_last)
88 | {
89 | ax.options.max = newx*2;
90 | plot.setupGrid();
91 | }
92 |
93 | plot.draw();
94 |
95 | // hack to update the profile points after dragging graph in edit mode
96 | updateProfileTable();
97 |
98 | var retx = points[didx*ps];
99 | var rety = points[didx*ps+1];
100 |
101 | plot.getPlaceholder().trigger('plotSeriesChange', [sidx, didx, retx, rety])
102 | });
103 | eventHolder.bind("dragend", function (e) {
104 | var sidx = drag.item.seriesIndex;
105 | var didx = drag.item.dataIndex;
106 | var s = plot.getData()[sidx];
107 | var ps = s.datapoints.pointsize;
108 | plot.getPlaceholder().trigger('plotFinalSeriesChange', [sidx, didx, s.datapoints.points[didx*ps], s.datapoints.points[didx*ps+1]])
109 | });
110 | }
111 | }
112 |
113 | plot.hooks.bindEvents.push(bindEvents);
114 | }
115 |
116 | $.plot.plugins.push({
117 | init: init,
118 | options: options,
119 | name: 'draggable',
120 | version: '1.0'
121 | });
122 | })(jQuery);
123 |
--------------------------------------------------------------------------------
/public/assets/css/bootstrap-modal.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * Bootstrap Modal
3 | *
4 | * Copyright Jordan Schroter
5 | * Licensed under the Apache License v2.0
6 | * http://www.apache.org/licenses/LICENSE-2.0
7 | *
8 | * Boostrap 3 patch for for bootstrap-modal. Include BEFORE bootstrap-modal.css!
9 | */
10 |
11 | body.modal-open,
12 | .modal-open .navbar-fixed-top,
13 | .modal-open .navbar-fixed-bottom {
14 | margin-right: 0;
15 | }
16 |
17 | .modal {
18 | left: 50%;
19 | bottom: auto;
20 | right: auto;
21 | padding: 0;
22 | width: 500px;
23 | margin-left: -250px;
24 | background-clip: padding-box;
25 | }
26 |
27 | .modal.container {
28 | max-width: none;
29 | }
30 |
31 |
32 | /*!
33 | * Bootstrap Modal
34 | *
35 | * Copyright Jordan Schroter
36 | * Licensed under the Apache License v2.0
37 | * http://www.apache.org/licenses/LICENSE-2.0
38 | *
39 | */
40 |
41 | .modal-open {
42 | overflow: hidden;
43 | }
44 |
45 |
46 | /* add a scroll bar to stop page from jerking around */
47 | .modal-open.page-overflow .page-container,
48 | .modal-open.page-overflow .page-container .navbar-fixed-top,
49 | .modal-open.page-overflow .page-container .navbar-fixed-bottom,
50 | .modal-open.page-overflow .modal-scrollable {
51 | overflow-y: scroll;
52 | }
53 |
54 | @media (max-width: 979px) {
55 | .modal-open.page-overflow .page-container .navbar-fixed-top,
56 | .modal-open.page-overflow .page-container .navbar-fixed-bottom {
57 | overflow-y: visible;
58 | }
59 | }
60 |
61 |
62 | .modal-scrollable {
63 | position: fixed;
64 | top: 0;
65 | bottom: 0;
66 | left: 0;
67 | right: 0;
68 | overflow: auto;
69 | }
70 |
71 | .modal {
72 | outline: none;
73 | position: absolute;
74 | margin-top: 0;
75 | top: 50%;
76 | overflow: visible; /* allow content to popup out (i.e tooltips) */
77 | }
78 |
79 | .modal.fade {
80 | top: -100%;
81 | -webkit-transition: opacity 0.3s linear, top 0.3s ease-out, bottom 0.3s ease-out, margin-top 0.3s ease-out;
82 | -moz-transition: opacity 0.3s linear, top 0.3s ease-out, bottom 0.3s ease-out, margin-top 0.3s ease-out;
83 | -o-transition: opacity 0.3s linear, top 0.3s ease-out, bottom 0.3s ease-out, margin-top 0.3s ease-out;
84 | transition: opacity 0.3s linear, top 0.3s ease-out, bottom 0.3s ease-out, margin-top 0.3s ease-out;
85 | }
86 |
87 | .modal.fade.in {
88 | top: 50%;
89 | }
90 |
91 | .modal-body {
92 | max-height: none;
93 | overflow: visible;
94 | }
95 |
96 | .modal.modal-absolute {
97 | position: absolute;
98 | z-index: 950;
99 | }
100 |
101 | .modal .loading-mask {
102 | position: absolute;
103 | top: 0;
104 | bottom: 0;
105 | left: 0;
106 | right: 0;
107 | background: #fff;
108 | border-radius: 6px;
109 | }
110 |
111 | .modal-backdrop.modal-absolute{
112 | position: absolute;
113 | z-index: 940;
114 | }
115 |
116 | .modal-backdrop,
117 | .modal-backdrop.fade.in{
118 | opacity: 0.7;
119 | filter: alpha(opacity=70);
120 | background: #3F3E3A;
121 | }
122 |
123 | .modal.container {
124 | width: 940px;
125 | margin-left: -470px;
126 | }
127 |
128 | /* Modal Overflow */
129 |
130 | .modal-overflow.modal {
131 | top: 1%;
132 | }
133 |
134 | .modal-overflow.modal.fade {
135 | top: -100%;
136 | }
137 |
138 | .modal-overflow.modal.fade.in {
139 | top: 1%;
140 | }
141 |
142 | .modal-overflow .modal-body {
143 | overflow: auto;
144 | -webkit-overflow-scrolling: touch;
145 | }
146 |
147 | /* Responsive */
148 |
149 | @media (min-width: 1200px) {
150 | .modal.container {
151 | width: 1170px;
152 | margin-left: -585px;
153 | }
154 | }
155 |
156 | @media (max-width: 979px) {
157 | .modal,
158 | .modal.container,
159 | .modal.modal-overflow {
160 | top: 1%;
161 | right: 1%;
162 | left: 1%;
163 | bottom: auto;
164 | width: auto !important;
165 | height: auto !important;
166 | margin: 0 !important;
167 | padding: 0 !important;
168 | }
169 |
170 | .modal.fade.in,
171 | .modal.container.fade.in,
172 | .modal.modal-overflow.fade.in {
173 | top: 1%;
174 | bottom: auto;
175 | }
176 |
177 | .modal-body,
178 | .modal-overflow .modal-body {
179 | position: static;
180 | margin: 0;
181 | height: auto !important;
182 | max-height: none !important;
183 | overflow: visible !important;
184 | }
185 |
186 | .modal-footer,
187 | .modal-overflow .modal-footer {
188 | position: static;
189 | }
190 | }
191 |
192 | .loading-spinner {
193 | position: absolute;
194 | top: 50%;
195 | left: 50%;
196 | margin: -12px 0 0 -12px;
197 | }
198 |
199 | /*
200 | Animate.css - http://daneden.me/animate
201 | Licensed under the ☺ license (http://licence.visualidiot.com/)
202 |
203 | Copyright (c) 2012 Dan Eden*/
204 |
205 | .animated {
206 | -webkit-animation-duration: 1s;
207 | -moz-animation-duration: 1s;
208 | -o-animation-duration: 1s;
209 | animation-duration: 1s;
210 | -webkit-animation-fill-mode: both;
211 | -moz-animation-fill-mode: both;
212 | -o-animation-fill-mode: both;
213 | animation-fill-mode: both;
214 | }
215 |
216 | @-webkit-keyframes shake {
217 | 0%, 100% {-webkit-transform: translateX(0);}
218 | 10%, 30%, 50%, 70%, 90% {-webkit-transform: translateX(-10px);}
219 | 20%, 40%, 60%, 80% {-webkit-transform: translateX(10px);}
220 | }
221 |
222 | @-moz-keyframes shake {
223 | 0%, 100% {-moz-transform: translateX(0);}
224 | 10%, 30%, 50%, 70%, 90% {-moz-transform: translateX(-10px);}
225 | 20%, 40%, 60%, 80% {-moz-transform: translateX(10px);}
226 | }
227 |
228 | @-o-keyframes shake {
229 | 0%, 100% {-o-transform: translateX(0);}
230 | 10%, 30%, 50%, 70%, 90% {-o-transform: translateX(-10px);}
231 | 20%, 40%, 60%, 80% {-o-transform: translateX(10px);}
232 | }
233 |
234 | @keyframes shake {
235 | 0%, 100% {transform: translateX(0);}
236 | 10%, 30%, 50%, 70%, 90% {transform: translateX(-10px);}
237 | 20%, 40%, 60%, 80% {transform: translateX(10px);}
238 | }
239 |
240 | .shake {
241 | -webkit-animation-name: shake;
242 | -moz-animation-name: shake;
243 | -o-animation-name: shake;
244 | animation-name: shake;
245 | }
246 |
--------------------------------------------------------------------------------
/kiln-tuner.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os
4 | import sys
5 | import csv
6 | import time
7 | import argparse
8 |
9 | try:
10 | sys.dont_write_bytecode = True
11 | import config
12 | sys.dont_write_bytecode = False
13 |
14 | except ImportError:
15 | print("Could not import config file.")
16 | print("Copy config.py.EXAMPLE to config.py and adapt it for your setup.")
17 | exit(1)
18 |
19 |
20 | def recordprofile(csvfile, targettemp):
21 |
22 | script_dir = os.path.dirname(os.path.realpath(__file__))
23 | sys.path.insert(0, script_dir + '/lib/')
24 |
25 | from oven import RealOven, SimulatedOven
26 |
27 | # open the file to log data to
28 | f = open(csvfile, 'w')
29 | csvout = csv.writer(f)
30 | csvout.writerow(['time', 'temperature'])
31 |
32 | # construct the oven
33 | if config.simulate:
34 | oven = SimulatedOven()
35 | oven.target = targettemp * 2 # insures max heating for simulation
36 | else:
37 | oven = RealOven()
38 |
39 | # Main loop:
40 | #
41 | # * heat the oven to the target temperature at maximum burn.
42 | # * when we reach it turn the heating off completely.
43 | # * wait for it to decay back to the target again.
44 | # * quit
45 | #
46 | # We record the temperature every config.sensor_time_wait
47 | try:
48 |
49 | # heating to target of 400F
50 | temp = 0
51 | sleepfor = config.sensor_time_wait
52 | stage = "heating"
53 | while(temp <= targettemp):
54 | if config.simulate:
55 | oven.heat_then_cool()
56 | else:
57 | oven.output.heat(sleepfor)
58 | temp = oven.board.temp_sensor.temperature() + \
59 | config.thermocouple_offset
60 |
61 | print("stage = %s, actual = %.2f, target = %.2f" % (stage,temp,targettemp))
62 | csvout.writerow([time.time(), temp])
63 | f.flush()
64 |
65 | # overshoot past target of 400F and then cooling down to 400F
66 | stage = "cooling"
67 | if config.simulate:
68 | oven.target = 0
69 | while(temp >= targettemp):
70 | if config.simulate:
71 | oven.heat_then_cool()
72 | else:
73 | oven.output.cool(sleepfor)
74 | temp = oven.board.temp_sensor.temperature() + \
75 | config.thermocouple_offset
76 |
77 | print("stage = %s, actual = %.2f, target = %.2f" % (stage,temp,targettemp))
78 | csvout.writerow([time.time(), temp])
79 | f.flush()
80 |
81 | finally:
82 | f.close()
83 | # ensure we always shut the oven down!
84 | if not config.simulate:
85 | oven.output.cool(0)
86 |
87 |
88 | def line(a, b, x):
89 | return a * x + b
90 |
91 |
92 | def invline(a, b, y):
93 | return (y - b) / a
94 |
95 |
96 | def plot(xdata, ydata,
97 | tangent_min, tangent_max, tangent_slope, tangent_offset,
98 | lower_crossing_x, upper_crossing_x):
99 | from matplotlib import pyplot
100 |
101 | minx = min(xdata)
102 | maxx = max(xdata)
103 | miny = min(ydata)
104 | maxy = max(ydata)
105 |
106 | pyplot.scatter(xdata, ydata)
107 |
108 | pyplot.plot([minx, maxx], [miny, miny], '--', color='purple')
109 | pyplot.plot([minx, maxx], [maxy, maxy], '--', color='purple')
110 |
111 | pyplot.plot(tangent_min[0], tangent_min[1], 'v', color='red')
112 | pyplot.plot(tangent_max[0], tangent_max[1], 'v', color='red')
113 | pyplot.plot([minx, maxx], [line(tangent_slope, tangent_offset, minx), line(tangent_slope, tangent_offset, maxx)], '--', color='red')
114 |
115 | pyplot.plot([lower_crossing_x, lower_crossing_x], [miny, maxy], '--', color='black')
116 | pyplot.plot([upper_crossing_x, upper_crossing_x], [miny, maxy], '--', color='black')
117 |
118 | pyplot.show()
119 |
120 |
121 | def calculate(filename, tangentdivisor, showplot):
122 | # parse the csv file
123 | xdata = []
124 | ydata = []
125 | filemintime = None
126 | with open(filename) as f:
127 | for row in csv.DictReader(f):
128 | try:
129 | time = float(row['time'])
130 | temp = float(row['temperature'])
131 | if filemintime is None:
132 | filemintime = time
133 |
134 | xdata.append(time - filemintime)
135 | ydata.append(temp)
136 | except ValueError:
137 | continue # just ignore bad values!
138 |
139 | # gather points for tangent line
140 | miny = min(ydata)
141 | maxy = max(ydata)
142 | midy = (maxy + miny) / 2
143 | yoffset = int((maxy - miny) / tangentdivisor)
144 | tangent_min = tangent_max = None
145 | for i in range(0, len(xdata)):
146 | rowx = xdata[i]
147 | rowy = ydata[i]
148 |
149 | if rowy >= (midy - yoffset) and tangent_min is None:
150 | tangent_min = (rowx, rowy)
151 | elif rowy >= (midy + yoffset) and tangent_max is None:
152 | tangent_max = (rowx, rowy)
153 |
154 | # calculate tangent line to the main temperature curve
155 | tangent_slope = (tangent_max[1] - tangent_min[1]) / (tangent_max[0] - tangent_min[0])
156 | tangent_offset = tangent_min[1] - line(tangent_slope, 0, tangent_min[0])
157 |
158 | # determine the point at which the tangent line crosses the min/max temperaturess
159 | lower_crossing_x = invline(tangent_slope, tangent_offset, miny)
160 | upper_crossing_x = invline(tangent_slope, tangent_offset, maxy)
161 |
162 | # compute parameters
163 | L = lower_crossing_x - min(xdata)
164 | T = upper_crossing_x - lower_crossing_x
165 |
166 | # Magic Ziegler-Nicols constants ahead!
167 | Kp = 1.2 * (T / L)
168 | Ti = 2 * L
169 | Td = 0.5 * L
170 | Ki = Kp / Ti
171 | Kd = Kp * Td
172 |
173 | # output to the user
174 | print("pid_kp = %s" % (Kp))
175 | print("pid_ki = %s" % (1 / Ki))
176 | print("pid_kd = %s" % (Kd))
177 |
178 |
179 | if showplot:
180 | plot(xdata, ydata,
181 | tangent_min, tangent_max, tangent_slope, tangent_offset,
182 | lower_crossing_x, upper_crossing_x)
183 |
184 |
185 | if __name__ == "__main__":
186 | parser = argparse.ArgumentParser(description='Kiln tuner')
187 | parser.add_argument('-c', '--calculate_only', action='store_true')
188 | parser.add_argument('-t', '--target_temp', type=float, default=400, help="Target temperature")
189 | parser.add_argument('-d', '--tangent_divisor', type=float, default=8, help="Adjust the tangent calculation to fit better. Must be >= 2 (default 8).")
190 | parser.add_argument('-s', '--showplot', action='store_true', help="draw plot so you can see tanget line and possibly change")
191 | args = parser.parse_args()
192 |
193 | csvfile = "tuning.csv"
194 | target = args.target_temp
195 | if config.temp_scale.lower() == "c":
196 | target = (target - 32)*5/9
197 | tangentdivisor = args.tangent_divisor
198 |
199 | # default behavior is to record profile to csv file tuning.csv
200 | # and then calculate pid values and print them
201 | if args.calculate_only:
202 | calculate(csvfile, tangentdivisor, args.showplot)
203 | else:
204 | recordprofile(csvfile, target)
205 | calculate(csvfile, tangentdivisor, args.showplot)
206 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Kiln Controller
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
Sensor Temp
31 |
Target Temp
32 |
Heat Rate
33 |
Cost
34 |
Status
35 |
36 |
37 |
38 |
25 °C
39 |
--- °C
40 |
--- °C
41 |
0.00
42 |
43 |
\ l [ I ♨
44 |
45 |
46 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | Simulate
64 | Start
65 |
66 |
Stop
67 |
68 |
69 |
70 | Schedule Name
71 |
72 |
73 | Save
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
104 |
105 |
106 | Selected Profile
107 | Estimated Runtime
108 | Estimated Power consumption
109 |
110 |
111 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
128 |
129 | Do your really want to delete this profile?
130 |
131 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
148 |
149 | Do your really want to overwrite this profile?
150 |
151 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
--------------------------------------------------------------------------------
/public/assets/js/state.js:
--------------------------------------------------------------------------------
1 | config = "";
2 | all = [];
3 | var table = "";
4 |
5 | var protocol = 'ws:';
6 | if (window.location.protocol == 'https:') {
7 | protocol = 'wss:';
8 | }
9 | var host = "" + protocol + "//" + window.location.hostname + ":" + window.location.port;
10 | var ws_status = new WebSocket(host+"/status");
11 | var ws_config = new WebSocket(host+"/config");
12 |
13 | ws_status.onmessage = function(e) {
14 | x = JSON.parse(e.data);
15 | if (x.pidstats) {
16 | x.pidstats["datetime"]=unix_to_yymmdd_hhmmss(x.pidstats.time);
17 | x.pidstats.err = x.pidstats.err*-1;
18 | x.pidstats.out = x.pidstats.out*100;
19 | x.pidstats.catching_up = x.catching_up
20 | if (x.catching_up == true) {
21 | x.pidstats.catchingup = x.pidstats.ispoint;
22 | }
23 | all.push(x.pidstats);
24 | }
25 | var str = JSON.stringify(x, null, 2);
26 | document.getElementById("state").innerHTML = ""+str+" "
27 | table.replaceData(latest(20));
28 | drawall(all);
29 |
30 | document.getElementById("error-current").innerHTML = rnd(x.pidstats.err);
31 | document.getElementById("error-1min").innerHTML = rnd(average("err",1,all));
32 | document.getElementById("error-5min").innerHTML = rnd(average("err",5,all));
33 | document.getElementById("error-15min").innerHTML = rnd(average("err",15,all));
34 |
35 | document.getElementById("temp").innerHTML = rnd(x.pidstats.ispoint);
36 | document.getElementById("target").innerHTML = rnd(x.pidstats.setpoint);
37 |
38 | document.getElementById("heat-pct").innerHTML = rnd(x.pidstats.out);
39 |
40 | document.getElementById("catching-up").innerHTML = rnd(percent_catching_up(all));
41 | };
42 |
43 | ws_config.onopen = function() {
44 | ws_config.send('GET');
45 | };
46 |
47 | ws_config.onmessage = function(e) {
48 | config = JSON.parse(e.data);
49 | //console.log(e);
50 | };
51 |
52 | create_table(all);
53 |
54 | //---------------------------------------------------------------------------
55 | function rnd(number) {
56 | return Number(number).toFixed(2);
57 | }
58 | //---------------------------------------------------------------------------
59 | function average(field,minutes,data) {
60 | if(data[0]!=null) {
61 | var t = data[data.length - 1].time;
62 | var oldest = t-(60*minutes);
63 | var q = "SELECT AVG("+ field + ") from ? where time>=" + oldest.toString();
64 | var avg = alasql(q,[data]);
65 | return avg[0]["AVG(err)"];
66 | }
67 | return 0;
68 | }
69 |
70 | //---------------------------------------------------------------------------
71 | function drawall(data) {
72 | draw_temps(data);
73 | draw_error(data);
74 | draw_heat(data);
75 | draw_p(data);
76 | draw_i(data);
77 | draw_d(data);
78 | }
79 |
80 | //---------------------------------------------------------------------------
81 | function draw_heat(data) {
82 | var traces=[];
83 | var rows = alasql('SELECT datetime, out from ?',[data]);
84 | var title = 'Heating Percent';
85 |
86 | var trace = {
87 | x: unpack(rows, 'datetime'),
88 | y: unpack(rows, 'out'),
89 | name: 'heat',
90 | mode: 'lines',
91 | line: { color: 'rgb(255,0,0)', width:2 }
92 | };
93 |
94 | traces.push(trace);
95 |
96 | spot = document.getElementById('heat');
97 | var layout = {
98 | title: title,
99 | showlegend: true,
100 | };
101 | Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
102 | }
103 |
104 | //---------------------------------------------------------------------------
105 | function draw_p(data) {
106 | var traces=[];
107 | var rows = alasql('SELECT datetime, p from ?',[data]);
108 | var title = 'Proportional';
109 |
110 | var trace = {
111 | x: unpack(rows, 'datetime'),
112 | y: unpack(rows, 'p'),
113 | name: 'p',
114 | mode: 'lines',
115 | line: { color: 'rgb(0,0,255)', width:2 }
116 | };
117 |
118 | traces.push(trace);
119 |
120 | spot = document.getElementById('p');
121 | var layout = {
122 | title: title,
123 | showlegend: true,
124 | };
125 | Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
126 | }
127 |
128 | //---------------------------------------------------------------------------
129 | function draw_i(data) {
130 | var traces=[];
131 | var rows = alasql('SELECT datetime, i from ?',[data]);
132 | var title = 'Integral';
133 |
134 | var trace = {
135 | x: unpack(rows, 'datetime'),
136 | y: unpack(rows, 'i'),
137 | name: 'i',
138 | mode: 'lines',
139 | line: { color: 'rgb(0,0,255)', width:2 }
140 | };
141 |
142 | traces.push(trace);
143 |
144 | spot = document.getElementById('i');
145 | var layout = {
146 | title: title,
147 | showlegend: true,
148 | };
149 | Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
150 | }
151 |
152 | //---------------------------------------------------------------------------
153 | function draw_d(data) {
154 | var traces=[];
155 | var rows = alasql('SELECT datetime, d from ?',[data]);
156 | var title = 'Derivative';
157 |
158 | var trace = {
159 | x: unpack(rows, 'datetime'),
160 | y: unpack(rows, 'd'),
161 | name: 'd',
162 | mode: 'lines',
163 | line: { color: 'rgb(0,0,255)', width:2 }
164 | };
165 |
166 | traces.push(trace);
167 |
168 | spot = document.getElementById('d');
169 | var layout = {
170 | title: title,
171 | showlegend: true,
172 | };
173 | Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
174 | }
175 |
176 |
177 | //---------------------------------------------------------------------------
178 | function draw_error(data) {
179 | var traces=[];
180 | var rows = alasql('SELECT datetime, err from ?',[data]);
181 | var title = 'Error';
182 |
183 | var trace = {
184 | x: unpack(rows, 'datetime'),
185 | y: unpack(rows, 'err'),
186 | name: 'error',
187 | mode: 'lines',
188 | line: { color: 'rgb(255,0,0)', width:2 }
189 | };
190 |
191 | traces.push(trace);
192 |
193 | spot = document.getElementById('error');
194 | var layout = {
195 | title: title,
196 | showlegend: true,
197 | //xaxis : { tickformat:'%b' },
198 | };
199 | Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
200 | }
201 |
202 | //---------------------------------------------------------------------------
203 | function draw_temps(data) {
204 | var traces=[];
205 | var rows = alasql('SELECT datetime, ispoint, setpoint, catchingup from ?',[data]);
206 | var title = 'Temperature and Target';
207 |
208 | var trace = {
209 | x: unpack(rows, 'datetime'),
210 | y: unpack(rows, 'setpoint'),
211 | name: 'target',
212 | mode: 'lines',
213 | line: { color: 'rgb(0,0,255)', width:2 }
214 | };
215 |
216 | traces.push(trace);
217 |
218 | trace = {
219 | x: unpack(rows, 'datetime'),
220 | y: unpack(rows, 'ispoint'),
221 | name: 'temp',
222 | mode: 'lines',
223 | line: { color: 'rgb(255,0,0)', width:2 }
224 | };
225 |
226 | traces.push(trace);
227 |
228 | trace = {
229 | x: unpack(rows, 'datetime'),
230 | y: unpack(rows, 'catchingup'),
231 | name: 'catchup',
232 | mode: 'markers',
233 | marker: { color: 'rgb(0,255,0)', width:3 }
234 | };
235 |
236 | traces.push(trace);
237 |
238 | spot = document.getElementById('temps');
239 | var layout = {
240 | title: title,
241 | showlegend: true,
242 | //xaxis : { tickformat:'%b' },
243 | };
244 | Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
245 | }
246 |
247 | //---------------------------------------------------------------------------
248 | function unpack(rows, key) {
249 | return rows.map(function(row) { return row[key]; });
250 | }
251 |
252 |
253 | //---------------------------------------------------------------------------
254 | function unix_to_yymmdd_hhmmss(t) {
255 | var date = new Date(t * 1000);
256 | var newd = new Date(date.getTime() - date.getTimezoneOffset()*60000);
257 | //return date.toLocaleString('en-US',{hour12:false}).replace(',','');
258 | return newd.toISOString().replace("T"," ").substring(0, 19);
259 | }
260 | //---------------------------------------------------------------------------
261 | function latest(n) {
262 | //sql = "select * from ? order by time desc limit " + n;
263 | sql = "select * from ? order by time desc";
264 | results = alasql(sql,[all]);
265 | return results;
266 | }
267 |
268 | //---------------------------------------------------------------------------
269 | function percent_catching_up(data) {
270 | var sql = "select sum(timeDelta) as slip from ? where catching_up=true";
271 | var a = alasql(sql,[data]);
272 | a = a[0]["slip"];
273 | sql = "select sum(timeDelta) as [all] from ?";
274 | var b = alasql(sql,[data]);
275 | b = b[0]["all"];
276 | return a/b*100;
277 | }
278 | //---------------------------------------------------------------------------
279 | function create_table(data) {
280 | table = new Tabulator("#state-table", {
281 | height:300,
282 | data:data, //assign data to table
283 | //layout:"fitColumns", //fit columns to width of table (optional)
284 | columns:[
285 | {title:"DateTime", field:"datetime"},
286 | {title:"Target", field:"setpoint"},
287 | {title:"Temp", field:"ispoint"},
288 | {title:"Error", field:"err"},
289 | {title:"P", field:"p"},
290 | {title:"I", field:"i"},
291 | {title:"D", field:"d"},
292 | {title:"Heat", field:"out"},
293 | {title:"Catching Up", field:"catching_up"},
294 | {title:"Time Delta", field:"timeDelta"},
295 | ]});
296 | }
297 |
298 | //---------------------------------------------------------------------------
299 | function csv_string() {
300 | table.download("csv", "kiln-state.csv");
301 | }
302 |
303 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Kiln Controller
2 | ==========
3 |
4 | Turns a Raspberry Pi into an inexpensive, web-enabled kiln controller.
5 |
6 | ## Features
7 |
8 | * supports [many boards](https://github.com/jbruce12000/kiln-controller/blob/main/docs/supported-boards.md) into addition to raspberry pi
9 | * supports Adafruit MAX31856 and MAX31855 thermocouple boards
10 | * support for K, J, N, R, S, T, E, or B type thermocouples
11 | * easy to create new kiln schedules and edit / modify existing schedules
12 | * no limit to runtime - fire for days if you want
13 | * view status from multiple devices at once - computer, tablet etc
14 | * real-time firing cost estimate
15 | * real-time heating rate displayed in degrees per hour
16 | * supports PID parameters you tune to your kiln
17 | * monitors temperature in kiln after schedule has ended
18 | * api for starting and stopping at any point in a schedule
19 | * accurate simulation
20 | * support for shifting schedule when kiln cannot heat quickly enough
21 | * support for skipping first part of profile to match current kiln temperature
22 | * prevents integral wind-up when temperatures not near the set point
23 | * automatic restarts if there is a power outage or other event
24 | * support for a watcher to page you via slack if you kiln is out of whack
25 | * easy scheduling of future kiln runs
26 |
27 |
28 | **Run Kiln Schedule**
29 |
30 | 
31 |
32 | **Edit Kiln Schedule**
33 |
34 | 
35 |
36 | ## Hardware
37 |
38 | ### Parts
39 |
40 | | Image | Hardware | Description |
41 | | ------| -------- | ----------- |
42 | |  | [Raspberry Pi](https://www.adafruit.com/category/105) | Virtually any Raspberry Pi will work since only a few GPIO pins are being used. Any board supported by [blinka](https://circuitpython.org/blinka) and has SPI should work. You'll also want to make sure the board has wifi. If you use something other than a Raspberry PI and get it to work, let me know. |
43 | |  | [Adafruit MAX31855](https://www.adafruit.com/product/269) or [Adafruit MAX31856](https://www.adafruit.com/product/3263) | Thermocouple breakout board |
44 | |  | [Thermocouple](https://www.auberins.com/index.php?main_page=product_info&cPath=20_3&products_id=39) | Invest in a heavy duty, ceramic thermocouple designed for kilns. Make sure the type will work with your thermocouple board. Adafruit-MAX31855 works only with K-type. Adafruit-MAX31856 is flexible and works with many types, but folks usually pick S-type. |
45 | |  | Breadboard | breadboard, ribbon cable, connector for pi's gpio pins & connecting wires |
46 | |  | Solid State Relay | Zero crossing, make sure it can handle the max current of your kiln. Even if the kiln is 220V you can buy a single [3 Phase SSR](https://www.auberins.com/index.php?main_page=product_info&cPath=2_30&products_id=331). It's like having 3 SSRs in one. Relays this big always require a heat sink. |
47 | |  | Electric Kiln | There are many old electric kilns on the market that don't have digital controls. You can pick one up on the used market cheaply. This controller will work with 110V or 220V (pick a proper SSR). My kiln is a Skutt KS-1018. |
48 |
49 | ### Schematic
50 |
51 | The pi has three gpio pins connected to the MAX31855 chip. D0 is configured as an input and CS and CLK are outputs. The signal that controls the solid state relay starts as a gpio output which drives a transistor acting as a switch in front of it. This transistor provides 5V and plenty of current to control the ssr. Since only four gpio pins are in use, any pi can be used for this project. See the [config](https://github.com/jbruce12000/kiln-controller/blob/main/config.py) file for gpio pin configuration.
52 |
53 | My controller plugs into the wall, and the kiln plugs into the controller.
54 |
55 | **WARNING** This project involves high voltages and high currents. Please make sure that anything you build conforms to local electrical codes and aligns with industry best practices.
56 |
57 | **Note:** The GPIO configuration in this schematic does not match the defaults, check [config](https://github.com/jbruce12000/kiln-controller/blob/main/config.py) and make sure the gpio pin configuration aligns with your actual connections.
58 |
59 | 
60 |
61 | *Note: I tried to power my ssr directly using a gpio pin, but it did not work. My ssr required 25ma to switch and rpi's gpio could only provide 16ma. YMMV.*
62 |
63 | ## Software
64 |
65 | ### Raspberry PI OS
66 |
67 | Download [Raspberry PI OS](https://www.raspberrypi.org/software/). Use Rasberry PI Imaging tool to install the OS on an SD card. Boot the OS, open a terminal and...
68 |
69 | $ sudo apt-get update
70 | $ sudo apt-get dist-upgrade
71 | $ git clone https://github.com/jbruce12000/kiln-controller
72 | $ cd kiln-controller
73 | $ python3 -m venv venv
74 | $ source venv/bin/activate
75 | $ pip install -r requirements.txt
76 |
77 | *Note: The above steps work on ubuntu if you prefer*
78 |
79 | ### Raspberry PI deployment
80 |
81 | If you're done playing around with simulations and want to deploy the code on a Raspberry PI to control a kiln, you'll need to do this in addition to the stuff listed above:
82 |
83 | $ sudo raspi-config
84 | interfacing options -> SPI -> Select Yes to enable
85 | select reboot
86 |
87 | ## Configuration
88 |
89 | All parameters are defined in config.py. You need to read through config.py carefully to understand each setting. Here are some of the most important settings:
90 |
91 | | Variable | Default | Description |
92 | | -------- | ------- | ----------- |
93 | | sensor_time_wait | 2 seconds | It's the duty cycle for the entire system. It's set to two seconds by default which means that a decision is made every 2s about whether to turn on relay[s] and for how long. If you use mechanical relays, you may want to increase this. At 2s, my SSR switches 11,000 times in 13 hours. |
94 | | temp_scale | f | f for farenheit, c for celcius |
95 | | pid parameters | | Used to tune your kiln. See PID Tuning. |
96 | | simulate | True | Simulate a kiln. Used to test the software by new users so they can check out the features. |
97 |
98 |
99 | ## Testing
100 |
101 | After you've completed connecting all the hardware together, there are scripts to test the thermocouple and to test the output to the solid state relay. Read the scripts below and then start your testing. First, activate the virtual environment like so...
102 |
103 | $ source venv/bin/activate
104 |
105 | then test the thermocouple with:
106 |
107 | $ ./test-thermocouple.py
108 |
109 | then test the output with:
110 |
111 | $ ./test-output.py
112 |
113 | and you can use this script to examine each pin's state including input/output/voltage on your board:
114 |
115 | $ ./gpioreadall.py
116 |
117 | ## PID Tuning
118 |
119 | Run the [autotuner](https://github.com/jbruce12000/kiln-controller/blob/main/docs/ziegler_tuning.md). It will heat your kiln to 400F, pass that, and then once it cools back down to 400F, it will calculate PID values which you must copy into config.py. No tuning is perfect across a wide temperature range. Here is a [PID Tuning Guide](https://github.com/jbruce12000/kiln-controller/blob/main/docs/pid_tuning.md) if you end up having to manually tune.
120 |
121 | There is a state view that can help with tuning. It shows the P,I, and D parameters over time plus allows for a csv dump of data collected. It also shows lots of other details that might help with troubleshooting issues. Go to /state.
122 |
123 | ## Usage
124 |
125 | ### Server Startup
126 |
127 | $ source venv/bin/activate; ./kiln-controller.py
128 |
129 | ### Autostart Server onBoot
130 | If you want the server to autostart on boot, run the following command:
131 |
132 | $ /home/pi/kiln-controller/start-on-boot
133 |
134 | ### Client Access
135 |
136 | Click http://127.0.0.1:8081 for local development or the IP
137 | of your PI and the port defined in config.py (default 8081).
138 |
139 | ### Simulation
140 |
141 | In config.py, set **simulate=True**. Start the server and select a profile and click Start. Simulations run at near real time.
142 |
143 | ### Scheduling a Kiln run
144 |
145 | If you want to schedule a kiln run to start in the future. Here are [examples](https://github.com/jbruce12000/kiln-controller/blob/main/docs/scheduling.md).
146 |
147 | ### Watcher
148 |
149 | If you're busy and do not want to sit around watching the web interface for problems, there is a watcher.py script which you can run on any machine in your local network or even on the raspberry pi which will watch the kiln-controller process to make sure it is running a schedule, and staying within a pre-defined temperature range. When things go bad, it sends messages to a slack channel you define. I have alerts set on my android phone for that specific slack channel. Here are detailed [instructions](https://github.com/jbruce12000/kiln-controller/blob/main/docs/watcher.md).
150 |
151 | ## License
152 |
153 | This program is free software: you can redistribute it and/or modify
154 | it under the terms of the GNU General Public License as published by
155 | the Free Software Foundation, either version 3 of the License, or
156 | (at your option) any later version.
157 |
158 | This program is distributed in the hope that it will be useful,
159 | but WITHOUT ANY WARRANTY; without even the implied warranty of
160 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
161 | GNU General Public License for more details.
162 |
163 | You should have received a copy of the GNU General Public License
164 | along with this program. If not, see .
165 |
166 | ## Support & Contact
167 |
168 | Please use the issue tracker for project related issues.
169 | If you're having trouble with hardware, I did too. Here is a [troubleshooting guide](https://github.com/jbruce12000/kiln-controller/blob/main/docs/troubleshooting.md) I created for testing RPi gpio pins.
170 |
171 | ## Origin
172 | This project was originally forked from https://github.com/apollo-ng/picoReflow but has diverged a large amount.
173 |
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from digitalio import DigitalInOut
4 | import busio
5 |
6 | ########################################################################
7 | #
8 | # General options
9 |
10 | ### Logging
11 | log_level = logging.INFO
12 | log_format = '%(asctime)s %(levelname)s %(name)s: %(message)s'
13 |
14 | ### Server
15 | listening_port = 8081
16 |
17 | ########################################################################
18 | # Cost Information
19 | #
20 | # This is used to calculate a cost estimate before a run. It's also used
21 | # to produce the actual cost during a run. My kiln has three
22 | # elements that when my switches are set to high, consume 9460 watts.
23 | kwh_rate = 0.1319 # cost per kilowatt hour per currency_type to calculate cost to run job
24 | kw_elements = 9.460 # if the kiln elements are on, the wattage in kilowatts
25 | currency_type = "$" # Currency Symbol to show when calculating cost to run job
26 |
27 | ########################################################################
28 | #
29 | # Hardware Setup (uses BCM Pin Numbering)
30 | #
31 | # kiln-controller.py uses SPI interface from the blinka library to read
32 | # temperature data from the adafruit-31855 or adafruit-31856.
33 | # Blinka supports many different boards. I've only tested raspberry pi.
34 | #
35 | # First you must decide whether to use hardware spi or software spi.
36 | #
37 | # Hardware SPI
38 | #
39 | # - faster
40 | # - requires 3 specific GPIO pins be used on rpis
41 | # - no pins are listed in this config file
42 | #
43 | # Software SPI
44 | #
45 | # - slower (which will not matter for reading a thermocouple
46 | # - can use any GPIO pins
47 | # - pins must be specified in this config file
48 |
49 | #######################################
50 | # SPI pins if you choose Hardware SPI #
51 | #######################################
52 | # On the raspberry pi, you MUST use predefined
53 | # pins for HW SPI. In the case of the adafruit-31855, only 3 pins are used:
54 | #
55 | # SPI0_SCLK = BCM pin 11 = CLK on the adafruit-31855
56 | # SPI0_MOSI = BCM pin 10 = not connected
57 | # SPI0_MISO = BCM pin 9 = D0 on the adafruit-31855
58 | #
59 | # plus a GPIO output to connect to CS. You can use any GPIO pin you want.
60 | # I chose gpio pin 5:
61 | #
62 | # GPIO5 = BCM pin 5 = CS on the adafruit-31855
63 | #
64 | # Note that NO pins are configured in this file for hardware spi
65 |
66 | #######################################
67 | # SPI pins if you choose software spi #
68 | #######################################
69 | # For software SPI, you can choose any GPIO pins you like.
70 | # You must connect clock, mosi, miso and cs each to a GPIO pin
71 | # and configure them below based on your connections.
72 |
73 | #######################################
74 | # SPI is Autoconfigured !!!
75 | #######################################
76 | # whether you choose HW or SW spi, it is autodetected. If you list the PINs
77 | # below, software spi is assumed.
78 |
79 | #######################################
80 | # Output to control the relay
81 | #######################################
82 | # A single GPIO pin is used to control a relay which controls the kiln.
83 | # I use GPIO pin 23.
84 |
85 | try:
86 | import board
87 | spi_sclk = board.D17 #spi clock
88 | spi_miso = board.D27 #spi Microcomputer In Serial Out
89 | spi_cs = board.D22 #spi Chip Select
90 | spi_mosi = board.D10 #spi Microcomputer Out Serial In (not connected)
91 | gpio_heat = board.D23 #output that controls relay
92 | gpio_heat_invert = False #invert the output state
93 | except (NotImplementedError,AttributeError):
94 | print("not running on blinka recognized board, probably a simulation")
95 |
96 | #######################################
97 | ### Thermocouple breakout boards
98 | #######################################
99 | # There are only two breakoutboards supported.
100 | # max31855 - only supports type K thermocouples
101 | # max31856 - supports many thermocouples
102 | max31855 = 1
103 | max31856 = 0
104 | # uncomment these two lines if using MAX-31856
105 | import adafruit_max31856
106 | thermocouple_type = adafruit_max31856.ThermocoupleType.K
107 |
108 | # here are the possible max-31856 thermocouple types
109 | # ThermocoupleType.B
110 | # ThermocoupleType.E
111 | # ThermocoupleType.J
112 | # ThermocoupleType.K
113 | # ThermocoupleType.N
114 | # ThermocoupleType.R
115 | # ThermocoupleType.S
116 | # ThermocoupleType.T
117 |
118 | ########################################################################
119 | #
120 | # If your kiln is above the starting temperature of the schedule when you
121 | # click the Start button... skip ahead and begin at the first point in
122 | # the schedule matching the current kiln temperature.
123 | seek_start = True
124 |
125 | ########################################################################
126 | #
127 | # duty cycle of the entire system in seconds
128 | #
129 | # Every N seconds a decision is made about switching the relay[s]
130 | # on & off and for how long. The thermocouple is read
131 | # temperature_average_samples times during and the average value is used.
132 | sensor_time_wait = 2
133 |
134 |
135 | ########################################################################
136 | #
137 | # PID parameters
138 | #
139 | # These parameters control kiln temperature change. These settings work
140 | # well with the simulated oven. You must tune them to work well with
141 | # your specific kiln. Note that the integral pid_ki is
142 | # inverted so that a smaller number means more integral action.
143 | pid_kp = 10 # Proportional 25,200,200
144 | pid_ki = 80 # Integral
145 | pid_kd = 220.83497910261562 # Derivative
146 |
147 | ########################################################################
148 | #
149 | # Initial heating and Integral Windup
150 | #
151 | # this setting is deprecated and is no longer used. this happens by
152 | # default and is the expected behavior.
153 | stop_integral_windup = True
154 |
155 | ########################################################################
156 | #
157 | # Simulation parameters
158 | simulate = True
159 | sim_t_env = 65 # deg
160 | sim_c_heat = 500.0 # J/K heat capacity of heat element
161 | sim_c_oven = 5000.0 # J/K heat capacity of oven
162 | sim_p_heat = 5450.0 # W heating power of oven
163 | sim_R_o_nocool = 0.5 # K/W thermal resistance oven -> environment
164 | sim_R_o_cool = 0.05 # K/W " with cooling
165 | sim_R_ho_noair = 0.1 # K/W thermal resistance heat element -> oven
166 | sim_R_ho_air = 0.05 # K/W " with internal air circulation
167 |
168 | # if you want simulations to happen faster than real time, this can be
169 | # set as high as 1000 to speed simulations up by 1000 times.
170 | sim_speedup_factor = 1
171 |
172 |
173 | ########################################################################
174 | #
175 | # Time and Temperature parameters
176 | #
177 | # If you change the temp_scale, all settings in this file are assumed to
178 | # be in that scale.
179 | temp_scale = "f" # c = Celsius | f = Fahrenheit - Unit to display
180 | time_scale_slope = "h" # s = Seconds | m = Minutes | h = Hours - Slope displayed in temp_scale per time_scale_slope
181 | time_scale_profile = "m" # s = Seconds | m = Minutes | h = Hours - Enter and view target time in time_scale_profile
182 |
183 | # emergency shutoff the profile if this temp is reached or exceeded.
184 | # This just shuts off the profile. If your SSR is working, your kiln will
185 | # naturally cool off. If your SSR has failed/shorted/closed circuit, this
186 | # means your kiln receives full power until your house burns down.
187 | # this should not replace you watching your kiln or use of a kiln-sitter
188 | emergency_shutoff_temp = 2264 #cone 7
189 |
190 | # If the current temperature is outside the pid control window,
191 | # delay the schedule until it does back inside. This allows for heating
192 | # and cooling as fast as possible and not continuing until temp is reached.
193 | kiln_must_catch_up = True
194 |
195 | # This setting is required.
196 | # This setting defines the window within which PID control occurs.
197 | # Outside this window (N degrees below or above the current target)
198 | # the elements are either 100% on because the kiln is too cold
199 | # or 100% off because the kiln is too hot. No integral builds up
200 | # outside the window. The bigger you make the window, the more
201 | # integral you will accumulate. This should be a positive integer.
202 | pid_control_window = 5 #degrees
203 |
204 | # thermocouple offset
205 | # If you put your thermocouple in ice water and it reads 36F, you can
206 | # set set this offset to -4 to compensate. This probably means you have a
207 | # cheap thermocouple. Invest in a better thermocouple.
208 | thermocouple_offset=0
209 |
210 | # number of samples of temperature to take over each duty cycle.
211 | # The larger the number, the more load on the board. K type
212 | # thermocouples have a precision of about 1/2 degree C.
213 | # The median of these samples is used for the temperature.
214 | temperature_average_samples = 10
215 |
216 | # Thermocouple AC frequency filtering - set to True if in a 50Hz locale, else leave at False for 60Hz locale
217 | ac_freq_50hz = False
218 |
219 | ########################################################################
220 | # Emergencies - or maybe not
221 | ########################################################################
222 | # There are all kinds of emergencies that can happen including:
223 | # - temperature is too high (emergency_shutoff_temp exceeded)
224 | # - lost connection to thermocouple
225 | # - unknown error with thermocouple
226 | # - too many errors in a short period from thermocouple
227 | # but in some cases, you might want to ignore a specific error, log it,
228 | # and continue running your profile instead of having the process die.
229 | #
230 | # You should only set these to True if you experience a problem
231 | # and WANT to ignore it to complete a firing.
232 | ignore_temp_too_high = False
233 | ignore_tc_lost_connection = False
234 | ignore_tc_cold_junction_range_error = False
235 | ignore_tc_range_error = False
236 | ignore_tc_cold_junction_temp_high = False
237 | ignore_tc_cold_junction_temp_low = False
238 | ignore_tc_temp_high = False
239 | ignore_tc_temp_low = False
240 | ignore_tc_voltage_error = False
241 | ignore_tc_short_errors = False
242 | ignore_tc_unknown_error = False
243 |
244 | # This overrides all possible thermocouple errors and prevents the
245 | # process from exiting.
246 | ignore_tc_too_many_errors = False
247 |
248 | ########################################################################
249 | # automatic restarts - if you have a power brown-out and the raspberry pi
250 | # reboots, this restarts your kiln where it left off in the firing profile.
251 | # This only happens if power comes back before automatic_restart_window
252 | # is exceeded (in minutes). The kiln-controller.py process must start
253 | # automatically on boot-up for this to work.
254 | # DO NOT put automatic_restart_state_file anywhere in /tmp. It could be
255 | # cleaned up (deleted) by the OS on boot.
256 | # The state file is written to disk every sensor_time_wait seconds (2s by default)
257 | # and is written in the same directory as config.py.
258 | automatic_restarts = True
259 | automatic_restart_window = 15 # max minutes since power outage
260 | automatic_restart_state_file = os.path.abspath(os.path.join(os.path.dirname( __file__ ),'state.json'))
261 |
262 | ########################################################################
263 | # load kiln profiles from this directory
264 | # created a repo where anyone can contribute profiles. The objective is
265 | # to load profiles from this repository by default.
266 | # See https://github.com/jbruce12000/kiln-profiles
267 | kiln_profiles_directory = os.path.abspath(os.path.join(os.path.dirname( __file__ ),"storage", "profiles"))
268 | #kiln_profiles_directory = os.path.abspath(os.path.join(os.path.dirname( __file__ ),'..','kiln-profiles','pottery'))
269 |
270 |
271 | ########################################################################
272 | # low temperature throttling of elements
273 | # kiln elements have lots of power and tend to drastically overshoot
274 | # at low temperatures. When under the set point and outside the PID
275 | # control window and below throttle_below_temp, only throttle_percent
276 | # of the elements are used max.
277 | # To prevent throttling, set throttle_percent to 100.
278 | throttle_below_temp = 300
279 | throttle_percent = 20
280 |
--------------------------------------------------------------------------------
/kiln-controller.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import time
4 | import os
5 | import sys
6 | import logging
7 | import json
8 |
9 | import bottle
10 | import gevent
11 | import geventwebsocket
12 | #from bottle import post, get
13 | from gevent.pywsgi import WSGIServer
14 | from geventwebsocket.handler import WebSocketHandler
15 | from geventwebsocket import WebSocketError
16 |
17 | # try/except removed here on purpose so folks can see why things break
18 | import config
19 |
20 | logging.basicConfig(level=config.log_level, format=config.log_format)
21 | log = logging.getLogger("kiln-controller")
22 | log.info("Starting kiln controller")
23 |
24 | script_dir = os.path.dirname(os.path.realpath(__file__))
25 | sys.path.insert(0, script_dir + '/lib/')
26 | profile_path = config.kiln_profiles_directory
27 |
28 | from oven import SimulatedOven, RealOven, Profile
29 | from ovenWatcher import OvenWatcher
30 |
31 | app = bottle.Bottle()
32 |
33 | if config.simulate == True:
34 | log.info("this is a simulation")
35 | oven = SimulatedOven()
36 | else:
37 | log.info("this is a real kiln")
38 | oven = RealOven()
39 | ovenWatcher = OvenWatcher(oven)
40 | # this ovenwatcher is used in the oven class for restarts
41 | oven.set_ovenwatcher(ovenWatcher)
42 |
43 | @app.route('/')
44 | def index():
45 | return bottle.redirect('/picoreflow/index.html')
46 |
47 | @app.route('/state')
48 | def state():
49 | return bottle.redirect('/picoreflow/state.html')
50 |
51 | @app.get('/api/stats')
52 | def handle_api():
53 | log.info("/api/stats command received")
54 | if hasattr(oven,'pid'):
55 | if hasattr(oven.pid,'pidstats'):
56 | return json.dumps(oven.pid.pidstats)
57 |
58 |
59 | @app.post('/api')
60 | def handle_api():
61 | log.info("/api is alive")
62 |
63 |
64 | # run a kiln schedule
65 | if bottle.request.json['cmd'] == 'run':
66 | wanted = bottle.request.json['profile']
67 | log.info('api requested run of profile = %s' % wanted)
68 |
69 | # start at a specific minute in the schedule
70 | # for restarting and skipping over early parts of a schedule
71 | startat = 0;
72 | if 'startat' in bottle.request.json:
73 | startat = bottle.request.json['startat']
74 |
75 | #Shut off seek if start time has been set
76 | allow_seek = True
77 | if startat > 0:
78 | allow_seek = False
79 |
80 | # get the wanted profile/kiln schedule
81 | profile = find_profile(wanted)
82 | if profile is None:
83 | return { "success" : False, "error" : "profile %s not found" % wanted }
84 |
85 | # FIXME juggling of json should happen in the Profile class
86 | profile_json = json.dumps(profile)
87 | profile = Profile(profile_json)
88 | oven.run_profile(profile, startat=startat, allow_seek=allow_seek)
89 | ovenWatcher.record(profile)
90 |
91 | if bottle.request.json['cmd'] == 'pause':
92 | log.info("api pause command received")
93 | oven.state = 'PAUSED'
94 |
95 | if bottle.request.json['cmd'] == 'resume':
96 | log.info("api resume command received")
97 | oven.state = 'RUNNING'
98 |
99 | if bottle.request.json['cmd'] == 'stop':
100 | log.info("api stop command received")
101 | oven.abort_run()
102 |
103 | if bottle.request.json['cmd'] == 'memo':
104 | log.info("api memo command received")
105 | memo = bottle.request.json['memo']
106 | log.info("memo=%s" % (memo))
107 |
108 | # get stats during a run
109 | if bottle.request.json['cmd'] == 'stats':
110 | log.info("api stats command received")
111 | if hasattr(oven,'pid'):
112 | if hasattr(oven.pid,'pidstats'):
113 | return json.dumps(oven.pid.pidstats)
114 |
115 | return { "success" : True }
116 |
117 | def find_profile(wanted):
118 | '''
119 | given a wanted profile name, find it and return the parsed
120 | json profile object or None.
121 | '''
122 | #load all profiles from disk
123 | profiles = get_profiles()
124 | json_profiles = json.loads(profiles)
125 |
126 | # find the wanted profile
127 | for profile in json_profiles:
128 | if profile['name'] == wanted:
129 | return profile
130 | return None
131 |
132 | @app.route('/picoreflow/:filename#.*#')
133 | def send_static(filename):
134 | log.debug("serving %s" % filename)
135 | return bottle.static_file(filename, root=os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "public"))
136 |
137 |
138 | def get_websocket_from_request():
139 | env = bottle.request.environ
140 | wsock = env.get('wsgi.websocket')
141 | if not wsock:
142 | abort(400, 'Expected WebSocket request.')
143 | return wsock
144 |
145 |
146 | @app.route('/control')
147 | def handle_control():
148 | wsock = get_websocket_from_request()
149 | log.info("websocket (control) opened")
150 | while True:
151 | try:
152 | message = wsock.receive()
153 | if message:
154 | log.info("Received (control): %s" % message)
155 | msgdict = json.loads(message)
156 | if msgdict.get("cmd") == "RUN":
157 | log.info("RUN command received")
158 | profile_obj = msgdict.get('profile')
159 | if profile_obj:
160 | profile_json = json.dumps(profile_obj)
161 | profile = Profile(profile_json)
162 | oven.run_profile(profile)
163 | ovenWatcher.record(profile)
164 | elif msgdict.get("cmd") == "SIMULATE":
165 | log.info("SIMULATE command received")
166 | #profile_obj = msgdict.get('profile')
167 | #if profile_obj:
168 | # profile_json = json.dumps(profile_obj)
169 | # profile = Profile(profile_json)
170 | #simulated_oven = Oven(simulate=True, time_step=0.05)
171 | #simulation_watcher = OvenWatcher(simulated_oven)
172 | #simulation_watcher.add_observer(wsock)
173 | #simulated_oven.run_profile(profile)
174 | #simulation_watcher.record(profile)
175 | elif msgdict.get("cmd") == "STOP":
176 | log.info("Stop command received")
177 | oven.abort_run()
178 | time.sleep(1)
179 | except WebSocketError as e:
180 | log.error(e)
181 | break
182 | log.info("websocket (control) closed")
183 |
184 |
185 | @app.route('/storage')
186 | def handle_storage():
187 | wsock = get_websocket_from_request()
188 | log.info("websocket (storage) opened")
189 | while True:
190 | try:
191 | message = wsock.receive()
192 | if not message:
193 | break
194 | log.debug("websocket (storage) received: %s" % message)
195 |
196 | try:
197 | msgdict = json.loads(message)
198 | except:
199 | msgdict = {}
200 |
201 | if message == "GET":
202 | log.info("GET command received")
203 | wsock.send(get_profiles())
204 | elif msgdict.get("cmd") == "DELETE":
205 | log.info("DELETE command received")
206 | profile_obj = msgdict.get('profile')
207 | if delete_profile(profile_obj):
208 | msgdict["resp"] = "OK"
209 | wsock.send(json.dumps(msgdict))
210 | #wsock.send(get_profiles())
211 | elif msgdict.get("cmd") == "PUT":
212 | log.info("PUT command received")
213 | profile_obj = msgdict.get('profile')
214 | #force = msgdict.get('force', False)
215 | force = True
216 | if profile_obj:
217 | #del msgdict["cmd"]
218 | if save_profile(profile_obj, force):
219 | msgdict["resp"] = "OK"
220 | else:
221 | msgdict["resp"] = "FAIL"
222 | log.debug("websocket (storage) sent: %s" % message)
223 |
224 | wsock.send(json.dumps(msgdict))
225 | wsock.send(get_profiles())
226 | time.sleep(1)
227 | except WebSocketError:
228 | break
229 | log.info("websocket (storage) closed")
230 |
231 |
232 | @app.route('/config')
233 | def handle_config():
234 | wsock = get_websocket_from_request()
235 | log.info("websocket (config) opened")
236 | while True:
237 | try:
238 | message = wsock.receive()
239 | wsock.send(get_config())
240 | except WebSocketError:
241 | break
242 | time.sleep(1)
243 | log.info("websocket (config) closed")
244 |
245 |
246 | @app.route('/status')
247 | def handle_status():
248 | wsock = get_websocket_from_request()
249 | ovenWatcher.add_observer(wsock)
250 | log.info("websocket (status) opened")
251 | while True:
252 | try:
253 | message = wsock.receive()
254 | wsock.send("Your message was: %r" % message)
255 | except WebSocketError:
256 | break
257 | time.sleep(1)
258 | log.info("websocket (status) closed")
259 |
260 |
261 | def get_profiles():
262 | try:
263 | profile_files = os.listdir(profile_path)
264 | except:
265 | profile_files = []
266 | profiles = []
267 | for filename in profile_files:
268 | with open(os.path.join(profile_path, filename), 'r') as f:
269 | profiles.append(json.load(f))
270 | profiles = normalize_temp_units(profiles)
271 | return json.dumps(profiles)
272 |
273 |
274 | def save_profile(profile, force=False):
275 | profile=add_temp_units(profile)
276 | profile_json = json.dumps(profile)
277 | filename = profile['name']+".json"
278 | filepath = os.path.join(profile_path, filename)
279 | if not force and os.path.exists(filepath):
280 | log.error("Could not write, %s already exists" % filepath)
281 | return False
282 | with open(filepath, 'w+') as f:
283 | f.write(profile_json)
284 | f.close()
285 | log.info("Wrote %s" % filepath)
286 | return True
287 |
288 | def add_temp_units(profile):
289 | """
290 | always store the temperature in degrees c
291 | this way folks can share profiles
292 | """
293 | if "temp_units" in profile:
294 | return profile
295 | profile['temp_units']="c"
296 | if config.temp_scale=="c":
297 | return profile
298 | if config.temp_scale=="f":
299 | profile=convert_to_c(profile);
300 | return profile
301 |
302 | def convert_to_c(profile):
303 | newdata=[]
304 | for (secs,temp) in profile["data"]:
305 | temp = (5/9)*(temp-32)
306 | newdata.append((secs,temp))
307 | profile["data"]=newdata
308 | return profile
309 |
310 | def convert_to_f(profile):
311 | newdata=[]
312 | for (secs,temp) in profile["data"]:
313 | temp = ((9/5)*temp)+32
314 | newdata.append((secs,temp))
315 | profile["data"]=newdata
316 | return profile
317 |
318 | def normalize_temp_units(profiles):
319 | normalized = []
320 | for profile in profiles:
321 | if "temp_units" in profile:
322 | if config.temp_scale == "f" and profile["temp_units"] == "c":
323 | profile = convert_to_f(profile)
324 | profile["temp_units"] = "f"
325 | normalized.append(profile)
326 | return normalized
327 |
328 | def delete_profile(profile):
329 | profile_json = json.dumps(profile)
330 | filename = profile['name']+".json"
331 | filepath = os.path.join(profile_path, filename)
332 | os.remove(filepath)
333 | log.info("Deleted %s" % filepath)
334 | return True
335 |
336 | def get_config():
337 | return json.dumps({"temp_scale": config.temp_scale,
338 | "time_scale_slope": config.time_scale_slope,
339 | "time_scale_profile": config.time_scale_profile,
340 | "kwh_rate": config.kwh_rate,
341 | "currency_type": config.currency_type})
342 |
343 | def main():
344 | ip = "0.0.0.0"
345 | port = config.listening_port
346 | log.info("listening on %s:%d" % (ip, port))
347 |
348 | server = WSGIServer((ip, port), app,
349 | handler_class=WebSocketHandler)
350 | server.serve_forever()
351 |
352 |
353 | if __name__ == "__main__":
354 | main()
355 |
--------------------------------------------------------------------------------
/public/assets/css/picoreflow.css:
--------------------------------------------------------------------------------
1 | @font-face{
2 | font-family: "Digi";
3 | src: url('/picoreflow/assets/fonts/digital-7-webfont.eot');
4 | src: local("Digital 7"),
5 | url('/picoreflow/assets/fonts/digital-7-webfont.woff') format("woff"),
6 | url('/picoreflow/assets/fonts/digital-7-webfont.ttf') format("truetype");
7 | }
8 |
9 | @font-face{
10 | font-family: "Tables";
11 | src: url('/picoreflow/assets/fonts/tables.eot');
12 | src: local("Tables"),
13 | url('/picoreflow/assets/fonts/tables.woff') format("woff"),
14 | url('/picoreflow/assets/fonts/tables.ttf') format("truetype");
15 | }
16 |
17 | body {
18 | background: #b9b6af;
19 | }
20 |
21 | #status {
22 | margin-top: 15px;
23 | color: #d8d3c5;
24 | font-weight: normal;
25 | -webkit-border-radius: 7px;
26 | -moz-border-radius: 7px;
27 | border-radius: 7px;
28 | border: 1px solid #ddd;
29 | height: 80px;
30 | -moz-box-shadow: 0 0 1.1em 0 rgba(0,0,0,0.55);
31 | -webkit-box-shadow: 0 0 1.5em 0 rgba(0,0,0,0.55);
32 | box-shadow: 0 0 1.1em 0 rgba(0,0,0,0.55);
33 | }
34 |
35 | .display {
36 | display: inline-block;
37 | text-align: right;
38 | padding-right: 5px;
39 | font-size: 40px;
40 | font-weight: normal;
41 | height: 40px;
42 | line-height: 40px;
43 | vertical-align: middle;
44 | color: #d8d3c5;
45 | border-color: #000000;
46 | }
47 |
48 | .ds-panel {
49 | background: #3F3E3A url('/picoreflow/assets/images/panel_bg.png') repeat;
50 | -moz-box-shadow: inset 0 0 42px 0 #000;
51 | -webkit-box-shadow: inset 0 0 42px 0 #000;
52 | box-shadow: inset 0 0 42px 0 #000;
53 | }
54 |
55 | .ds-title-panel {
56 | -webkit-border-top-left-radius: 6px;
57 | -webkit-border-top-right-radius: 6px;
58 | -moz-border-radius-topleft: 6px;
59 | -moz-border-radius-topright: 6px;
60 | border-top-left-radius: 6px;
61 | border-top-right-radius: 6px;
62 | background-image: -webkit-gradient(linear,left 0,left 100%,from(#f5f5f5),to(#e8e8e8));
63 | background-image: -webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);
64 | background-image: -moz-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);
65 | background-image: linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);
66 | background-repeat: repeat-x;
67 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5',endColorstr='#ffe8e8e8',GradientType=0);
68 | height: 18px;
69 | }
70 |
71 | .ds-title {
72 | display: inline-block;
73 | font-size: 10px;
74 | height: 18px;
75 | line-height: 18px;
76 | width: 100px;
77 | border-right: 1px solid #b9b6af;
78 | vertical-align: top;
79 | text-align: center;
80 | color: #8B8989;
81 | text-shadow: 1px 1px 0px rgba(255, 255, 255, 0.75);
82 | }
83 |
84 | .ds-num {
85 | width: 100px;
86 | font-family: "Digi";
87 | border-right: 1px solid #b9b6af;
88 | white-space: nowrap;
89 | text-shadow: 0 0 25px rgba(0, 0, 0, 1);
90 | }
91 |
92 | .ds-input {
93 | color: #d8d3c5;
94 | font-family: "Digi";
95 | font-size: 24px;
96 | text-shadow: 0 0 12px rgba(0, 0, 0, 1);
97 | background: #3F3E3A url('/picoreflow/assets/images/panel_bg.png') repeat;
98 | -moz-box-shadow: inset 0 0 12px 0 #000;
99 | -webkit-box-shadow: inset 0 0 12px 0 #000;
100 | box-shadow: inset 0 0 12px 0 #000;
101 | text-align: right;
102 | padding: 0;
103 | padding-right: 7px;
104 |
105 | }
106 |
107 | .ds-unit {
108 | font-family: "Arial";
109 | font-size: 22px;
110 | vertical-align: top;
111 | line-height: 32px;
112 | margin-left: 4px;
113 | }
114 |
115 | .ds-led {
116 | font-family: "Tables";
117 | font-size: 38px;
118 | text-align: center;
119 | color: #1F1E1A;
120 | text-shadow: 0 0 5px #000, 0 0 5px #000;
121 | border-left: 1px solid #b9b6af;
122 | height: 40px;
123 | width: 42px;
124 | display: inline-block;
125 | }
126 |
127 | .bar {
128 | width:70%;
129 | //padding: 2px 2px 0px 2px;
130 | //display:block;
131 | display: inline-block;
132 | font-family:arial;
133 | font-size:12px;
134 | background-color:#ca3c38;
135 | color:#000;
136 | //position:absolute;
137 | //bottom:0;
138 | }
139 |
140 | .ds-led-hazard-active {
141 | color: rgb(255, 204, 0);
142 | background: -moz-radial-gradient(center, ellipse cover, rgba(242,195,67,1) 0%, rgba(241,218,54,0.26) 100%); /* FF3.6+ */
143 | background: -webkit-gradient(radial, center center, 0px, center center, 100%, color-stop(0%,rgba(242,195,67,1)), color-stop(100%,rgba(241,218,54,0.26))); /* Chrome,Safari4+ */
144 | background: -webkit-radial-gradient(center, ellipse cover, rgba(242,195,67,1) 0%,rgba(241,218,54,0.26) 100%); /* Chrome10+,Safari5.1+ */
145 | background: -o-radial-gradient(center, ellipse cover, rgba(242,195,67,1) 0%,rgba(241,218,54,0.26) 100%); /* Opera 12+ */
146 | background: -ms-radial-gradient(center, ellipse cover, rgba(242,195,67,1) 0%,rgba(241,218,54,0.26) 100%); /* IE10+ */
147 | background: radial-gradient(ellipse at center, rgba(242,195,67,1) 0%,rgba(241,218,54,0.26) 100%); /* W3C */
148 | }
149 |
150 | .ds-led-door-open {
151 | color: rgb(214, 42, 0);
152 | background: -moz-radial-gradient(center, ellipse cover, rgba(242,195,67,1) 0%, rgba(241,218,54,0.26) 100%); /* FF3.6+ */
153 | background: -webkit-gradient(radial, center center, 0px, center center, 100%, color-stop(0%,rgba(242,195,67,1)), color-stop(100%,rgba(241,218,54,0.26))); /* Chrome,Safari4+ */
154 | background: -webkit-radial-gradient(center, ellipse cover, rgba(242,195,67,1) 0%,rgba(241,218,54,0.26) 100%); /* Chrome10+,Safari5.1+ */
155 | background: -o-radial-gradient(center, ellipse cover, rgba(242,195,67,1) 0%,rgba(241,218,54,0.26) 100%); /* Opera 12+ */
156 | background: -ms-radial-gradient(center, ellipse cover, rgba(242,195,67,1) 0%,rgba(241,218,54,0.26) 100%); /* IE10+ */
157 | background: radial-gradient(ellipse at center, rgba(242,195,67,1) 0%,rgba(241,218,54,0.26) 100%); /* W3C */
158 | }
159 |
160 | .ds-led-cool-active {
161 | color: rgb(74, 159, 255);
162 | background: -moz-radial-gradient(center, ellipse cover, rgba(124,197,239,1) 0%, rgba(48,144,209,0.26) 100%); /* FF3.6+ */
163 | background: -webkit-gradient(radial, center center, 0px, center center, 100%, color-stop(0%,rgba(124,197,239,1)), color-stop(100%,rgba(48,144,209,0.26))); /* Chrome,Safari4+ */
164 | background: -webkit-radial-gradient(center, ellipse cover, rgba(124,197,239,1) 0%,rgba(48,144,209,0.26) 100%); /* Chrome10+,Safari5.1+ */
165 | background: -o-radial-gradient(center, ellipse cover, rgba(124,197,239,1) 0%,rgba(48,144,209,0.26) 100%); /* Opera 12+ */
166 | background: -ms-radial-gradient(center, ellipse cover, rgba(124,197,239,1) 0%,rgba(48,144,209,0.26) 100%); /* IE10+ */
167 | background: radial-gradient(ellipse at center, rgba(124,197,239,1) 0%,rgba(48,144,209,0.26) 100%); /* W3C */
168 | }
169 |
170 | .ds-led-heat-active {
171 | color: rgb(214, 42, 0);
172 | background: -moz-radial-gradient(center, ellipse cover, rgba(214,25,25,1) 0%, rgba(214,42,0,0.26) 100%); /* FF3.6+ */
173 | background: -webkit-gradient(radial, center center, 0px, center center, 100%, color-stop(0%,rgba(214,25,25,1)), color-stop(100%,rgba(214,42,0,0.26))); /* Chrome,Safari4+ */
174 | background: -webkit-radial-gradient(center, ellipse cover, rgba(214,25,25,1) 0%,rgba(214,42,0,0.26) 100%); /* Chrome10+,Safari5.1+ */
175 | background: -o-radial-gradient(center, ellipse cover, rgba(214,25,25,1) 0%,rgba(214,42,0,0.26) 100%); /* Opera 12+ */
176 | background: -ms-radial-gradient(center, ellipse cover, rgba(214,25,25,1) 0%,rgba(214,42,0,0.26) 100%); /* IE10+ */
177 | background: radial-gradient(ellipse at center, rgba(214,25,25,1) 0%,rgba(214,42,0,0.26) 100%); /* W3C */
178 | }
179 |
180 | .ds-led-air-active {
181 | color: rgb(240, 240, 240);
182 | background: -moz-radial-gradient(center, ellipse cover, rgba(221,221,221,1) 0%, rgba(221,221,221,0.26) 100%); /* FF3.6+ */
183 | background: -webkit-gradient(radial, center center, 0px, center center, 100%, color-stop(0%,rgba(221,221,221,1)), color-stop(100%,rgba(221,221,221,0.26))); /* Chrome,Safari4+ */
184 | background: -webkit-radial-gradient(center, ellipse cover, rgba(221,221,221,1) 0%,rgba(221,221,221,0.26) 100%); /* Chrome10+,Safari5.1+ */
185 | background: -o-radial-gradient(center, ellipse cover, rgba(221,221,221,1) 0%,rgba(221,221,221,0.26) 100%); /* Opera 12+ */
186 | background: -ms-radial-gradient(center, ellipse cover, rgba(221,221,221,1) 0%,rgba(221,221,221,0.26) 100%); /* IE10+ */
187 | background: radial-gradient(ellipse at center, rgba(221,221,221,1) 0%,rgba(221,221,221,0.26) 100%); /* W3C */
188 | }
189 |
190 | .ds-trend {
191 | top: 0;
192 | text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.25), -1px -1px 0 rgba(0, 0, 0, 0.4);
193 | color: rgb(233, 233, 233);
194 | font-size: 20px;
195 | }
196 |
197 | .ds-target {
198 | color: #75890c;
199 | }
200 |
201 | .ds-state {
202 | border: none;
203 | width: initial;
204 | text-align: center;
205 | width: 168px;
206 | padding: 0;
207 | }
208 |
209 | .ds-state {
210 | border: none;
211 | width: initial;
212 | text-align: center;
213 | width: 210px;
214 | padding: 0;
215 | }
216 |
217 | .ds-text {
218 | border: none;
219 | width: initial;
220 | font-family: sans-serif;
221 | font-size: 32px;
222 | }
223 |
224 | .progress {
225 | -webkit-border-radius: 0;
226 | -moz-border-radius: 0;
227 | background: #3f3e3a;
228 | border-color: #000000;
229 | border-top: 1px solid #b9b6af;
230 | margin: 0;
231 | -webkit-border-bottom-left-radius: 7px;
232 | -webkit-border-bottom-right-radius: 7px;
233 | -moz-border-radius-bottomleft: 7px;
234 | -moz-border-radius-bottomright: 7px;
235 | }
236 |
237 | .progress-bar {
238 | background-color: #75890c;
239 | font-family: "Digi";
240 | font-size: 16px;
241 | }
242 |
243 |
244 | .panel-default {
245 | -webkit-border-radius: 7px;
246 | -moz-border-radius: 7px;
247 | border-radius: 7px;
248 | -moz-box-shadow: 0 0 1.1em 0 rgba(0,0,0,0.55);
249 | -webkit-box-shadow: 0 0 1.5em 0 rgba(0,0,0,0.55);
250 | box-shadow: 0 0 1.1em 0 rgba(0,0,0,0.55);
251 | margin-top: 15px;
252 | background: #3F3E3A url('/picoreflow/assets/images/panel_bg.png') repeat;
253 | }
254 |
255 | .panel-heading {
256 | background: #fafafa url('/picoreflow/assets/images/page_bg.png') repeat-x;
257 | overflow: hidden;
258 | padding: 10px;
259 | -webkit-border-top-left-radius: 5px;
260 | -webkit-border-top-right-radius: 5px;
261 | -moz-border-radius-topleft: 5px;
262 | -moz-border-radius-topright: 5px;
263 | border-top-left-radius: 5px;
264 | border-top-right-radius: 5px;
265 | }
266 |
267 | .panel-body {
268 | -moz-box-shadow: inset 0 0 42px 0 #000;
269 | -webkit-box-shadow: inset 0 0 42px 0 #000;
270 | box-shadow: inset 0 0 42px 0 #000;
271 | -webkit-border-bottom-left-radius: 7px;
272 | -webkit-border-bottom-right-radius: 7px;
273 | -moz-border-radius-bottomleft: 7px;
274 | -moz-border-radius-bottomright: 7px;
275 | background: rgba(0,0,0,0.2)
276 | }
277 |
278 | #profile_selector {
279 | border: 1px solid rgb(194, 194, 194);
280 | -webkit-border-radius: 5px;
281 | -moz-border-radius: 5px;
282 | border-radius: 5px;
283 | background: rgb(229,229,229);
284 | background: -moz-linear-gradient(top, rgba(229,229,229,1) 0%, rgba(255,255,255,1) 100%);
285 | background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(229,229,229,1)), color-stop(100%,rgba(255,255,255,1)));
286 | background: -webkit-linear-gradient(top, rgba(229,229,229,1) 0%,rgba(255,255,255,1) 100%);
287 | background: -o-linear-gradient(top, rgba(229,229,229,1) 0%,rgba(255,255,255,1) 100%);
288 | background: -ms-linear-gradient(top, rgba(229,229,229,1) 0%,rgba(255,255,255,1) 100%);
289 | background: linear-gradient(to bottom, rgba(229,229,229,1) 0%,rgba(255,255,255,1) 100%);
290 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#e5e5e5', endColorstr='#ffffff',GradientType=0 );
291 | padding: 5px;
292 | padding-bottom: 3px;
293 | padding-top: 3px;
294 | -moz-box-shadow: 0 0 1.1em 0 rgba(0,0,0,0.05),inset 0 0 9px 2px #000;
295 | -webkit-box-shadow: 0 0 1.5em 0 rgba(0,0,0,0.05),inset 0 0 0 0 #000;
296 | box-shadow: 0 0 1.1em 0 rgba(0,0,0,0.05),inset 0 0 0 0 #000;
297 | }
298 |
299 | .select2-container {
300 | position: relative;
301 | top: -3px;
302 | width: 190px;
303 | height: 34px;
304 | }
305 |
306 | .select2-container .select2-choice {
307 | line-height: 32px;
308 | }
309 |
310 | .graph {
311 | width: 100%;
312 | height: 300px;
313 | font-size: 14px;
314 | line-height: 1.2em;
315 | }
316 |
317 | .edit-points {
318 | margin-bottom: 5px;
319 | }
320 |
321 | .edit-points h3 {
322 | margin-top: 5px;
323 | margin-bottom: 15px;
324 | }
325 |
326 | .edit-points .row{
327 | margin-bottom: 5px;
328 | }
329 |
330 | .btn-success {
331 | background: rgb(164,179,87);
332 | background: -moz-linear-gradient(top, rgba(164,179,87,1) 0%, rgba(117,137,12,1) 100%);
333 | background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(164,179,87,1)), color-stop(100%,rgba(117,137,12,1)));
334 | background: -webkit-linear-gradient(top, rgba(164,179,87,1) 0%,rgba(117,137,12,1) 100%);
335 | background: -o-linear-gradient(top, rgba(164,179,87,1) 0%,rgba(117,137,12,1) 100%);
336 | background: -ms-linear-gradient(top, rgba(164,179,87,1) 0%,rgba(117,137,12,1) 100%);
337 | background: linear-gradient(to bottom, rgba(164,179,87,1) 0%,rgba(117,137,12,1) 100%);
338 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#a4b357', endColorstr='#75890c',GradientType=0 );
339 | background-color: #75890c;
340 | }
341 |
342 | .modal-content {
343 | background-image: linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);
344 | }
345 |
346 | .modal-body {
347 | background: #fafafa;
348 | }
349 |
350 | .modal-footer {
351 | margin-top: 0;
352 | }
353 |
354 | .modal-body .table {
355 | margin-bottom: 0;
356 | }
357 |
358 | .select2-container .select2-choice {
359 | height: 34px;
360 | margin-top: 1px;
361 | }
362 |
363 | .modal.fade.in {
364 | top: 10%;
365 | }
366 |
367 | .alert {
368 | background-image: -webkit-gradient(linear,left 0,left 100%,from(#f5f5f5),to(#e8e8e8));
369 | background-image: -webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);
370 | background-image: -moz-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);
371 | background-image: linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);
372 | background-repeat: repeat-x;
373 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5',endColorstr='#ffe8e8e8',GradientType=0);
374 | -moz-box-shadow: 0 0 1.1em 0 rgba(0,0,0,0.75);
375 | -webkit-box-shadow: 0 0 1.5em 0 rgba(0,0,0,0.75);
376 | box-shadow: 0 0 1.1em 0 rgba(0,0,0,0.75);
377 | }
378 |
379 | .alert-success {
380 | background: rgb(164,179,87);
381 | background: -moz-linear-gradient(top, rgba(164,179,87,1) 0%, rgba(117,137,12,1) 100%);
382 | background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(164,179,87,1)), color-stop(100%,rgba(117,137,12,1)));
383 | background: -webkit-linear-gradient(top, rgba(164,179,87,1) 0%,rgba(117,137,12,1) 100%);
384 | background: -o-linear-gradient(top, rgba(164,179,87,1) 0%,rgba(117,137,12,1) 100%);
385 | background: -ms-linear-gradient(top, rgba(164,179,87,1) 0%,rgba(117,137,12,1) 100%);
386 | background: linear-gradient(to bottom, rgba(164,179,87,1) 0%,rgba(117,137,12,1) 100%);
387 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#a4b357', endColorstr='#75890c',GradientType=0 );
388 | color: #fff;
389 | }
390 |
391 | .alert-error {
392 | background: rgb(206,57,20);
393 | background: -moz-linear-gradient(top, rgba(206,57,20,1) 0%, rgba(163,38,0,1) 100%);
394 | background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(206,57,20,1)), color-stop(100%,rgba(163,38,0,1)));
395 | background: -webkit-linear-gradient(top, rgba(206,57,20,1) 0%,rgba(163,38,0,1) 100%);
396 | background: -o-linear-gradient(top, rgba(206,57,20,1) 0%,rgba(163,38,0,1) 100%);
397 | background: -ms-linear-gradient(top, rgba(206,57,20,1) 0%,rgba(163,38,0,1) 100%);
398 | background: linear-gradient(to bottom, rgba(206,57,20,1) 0%,rgba(163,38,0,1) 100%);
399 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ce3914', endColorstr='#a32600',GradientType=0 );
400 | color: #fff;
401 | }
402 |
403 | .modal-body td {
404 | width: 50%;
405 | }
406 |
407 | .table-responsive {
408 | overflow-x: hidden;
409 | overflow-y: hidden;
410 | }
411 |
--------------------------------------------------------------------------------
/public/assets/css/bootstrap-theme.min.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * Bootstrap v3.0.2 by @fat and @mdo
3 | * Copyright 2013 Twitter, Inc.
4 | * Licensed under http://www.apache.org/licenses/LICENSE-2.0
5 | *
6 | * Designed and built with all the love in the world by @mdo and @fat.
7 | */
8 |
9 | .btn-default,.btn-primary,.btn-success,.btn-info,.btn-warning,.btn-danger{text-shadow:0 -1px 0 rgba(0,0,0,0.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075)}.btn-default:active,.btn-primary:active,.btn-success:active,.btn-info:active,.btn-warning:active,.btn-danger:active,.btn-default.active,.btn-primary.active,.btn-success.active,.btn-info.active,.btn-warning.active,.btn-danger.active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn:active,.btn.active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-gradient(linear,left 0,left 100%,from(#fff),to(#e0e0e0));background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-moz-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff',endColorstr='#ffe0e0e0',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-default:hover,.btn-default:focus{background-color:#e0e0e0;background-position:0 -15px}.btn-default:active,.btn-default.active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-primary{background-image:-webkit-gradient(linear,left 0,left 100%,from(#428bca),to(#2d6ca2));background-image:-webkit-linear-gradient(top,#428bca 0,#2d6ca2 100%);background-image:-moz-linear-gradient(top,#428bca 0,#2d6ca2 100%);background-image:linear-gradient(to bottom,#428bca 0,#2d6ca2 100%);background-repeat:repeat-x;border-color:#2b669a;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff2d6ca2',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-primary:hover,.btn-primary:focus{background-color:#2d6ca2;background-position:0 -15px}.btn-primary:active,.btn-primary.active{background-color:#2d6ca2;border-color:#2b669a}.btn-success{background-image:-webkit-gradient(linear,left 0,left 100%,from(#5cb85c),to(#419641));background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-moz-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);background-repeat:repeat-x;border-color:#3e8f3e;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c',endColorstr='#ff419641',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-success:hover,.btn-success:focus{background-color:#419641;background-position:0 -15px}.btn-success:active,.btn-success.active{background-color:#419641;border-color:#3e8f3e}.btn-warning{background-image:-webkit-gradient(linear,left 0,left 100%,from(#f0ad4e),to(#eb9316));background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-moz-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);background-repeat:repeat-x;border-color:#e38d13;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e',endColorstr='#ffeb9316',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-warning:hover,.btn-warning:focus{background-color:#eb9316;background-position:0 -15px}.btn-warning:active,.btn-warning.active{background-color:#eb9316;border-color:#e38d13}.btn-danger{background-image:-webkit-gradient(linear,left 0,left 100%,from(#d9534f),to(#c12e2a));background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-moz-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);background-repeat:repeat-x;border-color:#b92c28;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f',endColorstr='#ffc12e2a',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-danger:hover,.btn-danger:focus{background-color:#c12e2a;background-position:0 -15px}.btn-danger:active,.btn-danger.active{background-color:#c12e2a;border-color:#b92c28}.btn-info{background-image:-webkit-gradient(linear,left 0,left 100%,from(#5bc0de),to(#2aabd2));background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-moz-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);background-repeat:repeat-x;border-color:#28a4c9;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de',endColorstr='#ff2aabd2',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-info:hover,.btn-info:focus{background-color:#2aabd2;background-position:0 -15px}.btn-info:active,.btn-info.active{background-color:#2aabd2;border-color:#28a4c9}.thumbnail,.img-thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.075);box-shadow:0 1px 2px rgba(0,0,0,0.075)}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-color:#e8e8e8;background-image:-webkit-gradient(linear,left 0,left 100%,from(#f5f5f5),to(#e8e8e8));background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-moz-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5',endColorstr='#ffe8e8e8',GradientType=0)}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{background-color:#357ebd;background-image:-webkit-gradient(linear,left 0,left 100%,from(#428bca),to(#357ebd));background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:-moz-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff357ebd',GradientType=0)}.navbar-default{background-image:-webkit-gradient(linear,left 0,left 100%,from(#fff),to(#f8f8f8));background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-moz-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);background-repeat:repeat-x;border-radius:4px;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff',endColorstr='#fff8f8f8',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 5px rgba(0,0,0,0.075);box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 5px rgba(0,0,0,0.075)}.navbar-default .navbar-nav>.active>a{background-image:-webkit-gradient(linear,left 0,left 100%,from(#ebebeb),to(#f3f3f3));background-image:-webkit-linear-gradient(top,#ebebeb 0,#f3f3f3 100%);background-image:-moz-linear-gradient(top,#ebebeb 0,#f3f3f3 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f3f3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb',endColorstr='#fff3f3f3',GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,0.075);box-shadow:inset 0 3px 9px rgba(0,0,0,0.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,0.25)}.navbar-inverse{background-image:-webkit-gradient(linear,left 0,left 100%,from(#3c3c3c),to(#222));background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-moz-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c',endColorstr='#ff222222',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.navbar-inverse .navbar-nav>.active>a{background-image:-webkit-gradient(linear,left 0,left 100%,from(#222),to(#282828));background-image:-webkit-linear-gradient(top,#222 0,#282828 100%);background-image:-moz-linear-gradient(top,#222 0,#282828 100%);background-image:linear-gradient(to bottom,#222 0,#282828 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222',endColorstr='#ff282828',GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,0.25);box-shadow:inset 0 3px 9px rgba(0,0,0,0.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,0.25)}.navbar-static-top,.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}.alert{text-shadow:0 1px 0 rgba(255,255,255,0.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.25),0 1px 2px rgba(0,0,0,0.05);box-shadow:inset 0 1px 0 rgba(255,255,255,0.25),0 1px 2px rgba(0,0,0,0.05)}.alert-success{background-image:-webkit-gradient(linear,left 0,left 100%,from(#dff0d8),to(#c8e5bc));background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-moz-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);background-repeat:repeat-x;border-color:#b2dba1;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8',endColorstr='#ffc8e5bc',GradientType=0)}.alert-info{background-image:-webkit-gradient(linear,left 0,left 100%,from(#d9edf7),to(#b9def0));background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-moz-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);background-repeat:repeat-x;border-color:#9acfea;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7',endColorstr='#ffb9def0',GradientType=0)}.alert-warning{background-image:-webkit-gradient(linear,left 0,left 100%,from(#fcf8e3),to(#f8efc0));background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-moz-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);background-repeat:repeat-x;border-color:#f5e79e;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3',endColorstr='#fff8efc0',GradientType=0)}.alert-danger{background-image:-webkit-gradient(linear,left 0,left 100%,from(#f2dede),to(#e7c3c3));background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-moz-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);background-repeat:repeat-x;border-color:#dca7a7;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede',endColorstr='#ffe7c3c3',GradientType=0)}.progress{background-image:-webkit-gradient(linear,left 0,left 100%,from(#ebebeb),to(#f5f5f5));background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-moz-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb',endColorstr='#fff5f5f5',GradientType=0)}.progress-bar{background-image:-webkit-gradient(linear,left 0,left 100%,from(#428bca),to(#3071a9));background-image:-webkit-linear-gradient(top,#428bca 0,#3071a9 100%);background-image:-moz-linear-gradient(top,#428bca 0,#3071a9 100%);background-image:linear-gradient(to bottom,#428bca 0,#3071a9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff3071a9',GradientType=0)}.progress-bar-success{background-image:-webkit-gradient(linear,left 0,left 100%,from(#5cb85c),to(#449d44));background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-moz-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c',endColorstr='#ff449d44',GradientType=0)}.progress-bar-info{background-image:-webkit-gradient(linear,left 0,left 100%,from(#5bc0de),to(#31b0d5));background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-moz-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de',endColorstr='#ff31b0d5',GradientType=0)}.progress-bar-warning{background-image:-webkit-gradient(linear,left 0,left 100%,from(#f0ad4e),to(#ec971f));background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-moz-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e',endColorstr='#ffec971f',GradientType=0)}.progress-bar-danger{background-image:-webkit-gradient(linear,left 0,left 100%,from(#d9534f),to(#c9302c));background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-moz-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f',endColorstr='#ffc9302c',GradientType=0)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.075);box-shadow:0 1px 2px rgba(0,0,0,0.075)}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{text-shadow:0 -1px 0 #3071a9;background-image:-webkit-gradient(linear,left 0,left 100%,from(#428bca),to(#3278b3));background-image:-webkit-linear-gradient(top,#428bca 0,#3278b3 100%);background-image:-moz-linear-gradient(top,#428bca 0,#3278b3 100%);background-image:linear-gradient(to bottom,#428bca 0,#3278b3 100%);background-repeat:repeat-x;border-color:#3278b3;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff3278b3',GradientType=0)}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.05);box-shadow:0 1px 2px rgba(0,0,0,0.05)}.panel-default>.panel-heading{background-image:-webkit-gradient(linear,left 0,left 100%,from(#f5f5f5),to(#e8e8e8));background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-moz-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5',endColorstr='#ffe8e8e8',GradientType=0)}.panel-primary>.panel-heading{background-image:-webkit-gradient(linear,left 0,left 100%,from(#428bca),to(#357ebd));background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:-moz-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff357ebd',GradientType=0)}.panel-success>.panel-heading{background-image:-webkit-gradient(linear,left 0,left 100%,from(#dff0d8),to(#d0e9c6));background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-moz-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8',endColorstr='#ffd0e9c6',GradientType=0)}.panel-info>.panel-heading{background-image:-webkit-gradient(linear,left 0,left 100%,from(#d9edf7),to(#c4e3f3));background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-moz-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7',endColorstr='#ffc4e3f3',GradientType=0)}.panel-warning>.panel-heading{background-image:-webkit-gradient(linear,left 0,left 100%,from(#fcf8e3),to(#faf2cc));background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-moz-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3',endColorstr='#fffaf2cc',GradientType=0)}.panel-danger>.panel-heading{background-image:-webkit-gradient(linear,left 0,left 100%,from(#f2dede),to(#ebcccc));background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-moz-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede',endColorstr='#ffebcccc',GradientType=0)}.well{background-image:-webkit-gradient(linear,left 0,left 100%,from(#e8e8e8),to(#f5f5f5));background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-moz-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);background-repeat:repeat-x;border-color:#dcdcdc;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8',endColorstr='#fff5f5f5',GradientType=0);-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,0.05),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 3px rgba(0,0,0,0.05),0 1px 0 rgba(255,255,255,0.1)}
--------------------------------------------------------------------------------
/public/assets/css/select2.css:
--------------------------------------------------------------------------------
1 | /*
2 | Version: 3.4.1 Timestamp: Thu Jun 27 18:02:10 PDT 2013
3 | */
4 | .select2-container {
5 | margin: 0;
6 | position: relative;
7 | display: inline-block;
8 | /* inline-block for ie7 */
9 | zoom: 1;
10 | *display: inline;
11 | vertical-align: middle;
12 | }
13 |
14 | .select2-container,
15 | .select2-drop,
16 | .select2-search,
17 | .select2-search input{
18 | /*
19 | Force border-box so that % widths fit the parent
20 | container without overlap because of margin/padding.
21 |
22 | More Info : http://www.quirksmode.org/css/box.html
23 | */
24 | -webkit-box-sizing: border-box; /* webkit */
25 | -khtml-box-sizing: border-box; /* konqueror */
26 | -moz-box-sizing: border-box; /* firefox */
27 | -ms-box-sizing: border-box; /* ie */
28 | box-sizing: border-box; /* css3 */
29 | }
30 |
31 | .select2-container .select2-choice {
32 | display: block;
33 | height: 26px;
34 | padding: 0 0 0 8px;
35 | overflow: hidden;
36 | position: relative;
37 |
38 | border: 1px solid #aaa;
39 | white-space: nowrap;
40 | line-height: 26px;
41 | color: #444;
42 | text-decoration: none;
43 |
44 | -webkit-border-radius: 4px;
45 | -moz-border-radius: 4px;
46 | border-radius: 4px;
47 |
48 | -webkit-background-clip: padding-box;
49 | -moz-background-clip: padding;
50 | background-clip: padding-box;
51 |
52 | -webkit-touch-callout: none;
53 | -webkit-user-select: none;
54 | -khtml-user-select: none;
55 | -moz-user-select: none;
56 | -ms-user-select: none;
57 | user-select: none;
58 |
59 | background-color: #fff;
60 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.5, white));
61 | background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, white 50%);
62 | background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, white 50%);
63 | background-image: -o-linear-gradient(bottom, #eeeeee 0%, #ffffff 50%);
64 | background-image: -ms-linear-gradient(top, #ffffff 0%, #eeeeee 50%);
65 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#ffffff', endColorstr = '#eeeeee', GradientType = 0);
66 | background-image: linear-gradient(top, #ffffff 0%, #eeeeee 50%);
67 | }
68 |
69 | .select2-container.select2-drop-above .select2-choice {
70 | border-bottom-color: #aaa;
71 |
72 | -webkit-border-radius:0 0 4px 4px;
73 | -moz-border-radius:0 0 4px 4px;
74 | border-radius:0 0 4px 4px;
75 |
76 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.9, white));
77 | background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, white 90%);
78 | background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, white 90%);
79 | background-image: -o-linear-gradient(bottom, #eeeeee 0%, white 90%);
80 | background-image: -ms-linear-gradient(top, #eeeeee 0%,#ffffff 90%);
81 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#eeeeee',GradientType=0 );
82 | background-image: linear-gradient(top, #eeeeee 0%,#ffffff 90%);
83 | }
84 |
85 | .select2-container.select2-allowclear .select2-choice .select2-chosen {
86 | margin-right: 42px;
87 | }
88 |
89 | .select2-container .select2-choice > .select2-chosen {
90 | margin-right: 26px;
91 | display: block;
92 | overflow: hidden;
93 |
94 | white-space: nowrap;
95 |
96 | -ms-text-overflow: ellipsis;
97 | -o-text-overflow: ellipsis;
98 | text-overflow: ellipsis;
99 | }
100 |
101 | .select2-container .select2-choice abbr {
102 | display: none;
103 | width: 12px;
104 | height: 12px;
105 | position: absolute;
106 | right: 24px;
107 | top: 8px;
108 |
109 | font-size: 1px;
110 | text-decoration: none;
111 |
112 | border: 0;
113 | background: url('select2.png') right top no-repeat;
114 | cursor: pointer;
115 | outline: 0;
116 | }
117 |
118 | .select2-container.select2-allowclear .select2-choice abbr {
119 | display: inline-block;
120 | }
121 |
122 | .select2-container .select2-choice abbr:hover {
123 | background-position: right -11px;
124 | cursor: pointer;
125 | }
126 |
127 | .select2-drop-undermask {
128 | border: 0;
129 | margin: 0;
130 | padding: 0;
131 | position: absolute;
132 | left: 0;
133 | top: 0;
134 | z-index: 9998;
135 | background-color: transparent;
136 | filter: alpha(opacity=0);
137 | }
138 |
139 | .select2-drop-mask {
140 | border: 0;
141 | margin: 0;
142 | padding: 0;
143 | position: absolute;
144 | left: 0;
145 | top: 0;
146 | z-index: 9998;
147 | /* styles required for IE to work */
148 | background-color: #fff;
149 | opacity: 0;
150 | filter: alpha(opacity=0);
151 | }
152 |
153 | .select2-drop {
154 | width: 100%;
155 | margin-top: -1px;
156 | position: absolute;
157 | z-index: 9999;
158 | top: 100%;
159 |
160 | background: #fff;
161 | color: #000;
162 | border: 1px solid #aaa;
163 | border-top: 0;
164 |
165 | -webkit-border-radius: 0 0 4px 4px;
166 | -moz-border-radius: 0 0 4px 4px;
167 | border-radius: 0 0 4px 4px;
168 |
169 | -webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
170 | -moz-box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
171 | box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
172 | }
173 |
174 | .select2-drop-auto-width {
175 | border-top: 1px solid #aaa;
176 | width: auto;
177 | }
178 |
179 | .select2-drop-auto-width .select2-search {
180 | padding-top: 4px;
181 | }
182 |
183 | .select2-drop.select2-drop-above {
184 | margin-top: 1px;
185 | border-top: 1px solid #aaa;
186 | border-bottom: 0;
187 |
188 | -webkit-border-radius: 4px 4px 0 0;
189 | -moz-border-radius: 4px 4px 0 0;
190 | border-radius: 4px 4px 0 0;
191 |
192 | -webkit-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
193 | -moz-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
194 | box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
195 | }
196 |
197 | .select2-drop-active {
198 | border: 1px solid #75890c;
199 | border-top: none;
200 | }
201 |
202 | .select2-drop.select2-drop-above.select2-drop-active {
203 | border-top: 1px solid #75890c;
204 | }
205 |
206 | .select2-container .select2-choice .select2-arrow {
207 | display: inline-block;
208 | width: 18px;
209 | height: 100%;
210 | position: absolute;
211 | right: 0;
212 | top: 0;
213 |
214 | border-left: 1px solid #aaa;
215 | -webkit-border-radius: 0 4px 4px 0;
216 | -moz-border-radius: 0 4px 4px 0;
217 | border-radius: 0 4px 4px 0;
218 |
219 | -webkit-background-clip: padding-box;
220 | -moz-background-clip: padding;
221 | background-clip: padding-box;
222 |
223 | background: #ccc;
224 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #ccc), color-stop(0.6, #eee));
225 | background-image: -webkit-linear-gradient(center bottom, #ccc 0%, #eee 60%);
226 | background-image: -moz-linear-gradient(center bottom, #ccc 0%, #eee 60%);
227 | background-image: -o-linear-gradient(bottom, #ccc 0%, #eee 60%);
228 | background-image: -ms-linear-gradient(top, #cccccc 0%, #eeeeee 60%);
229 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#eeeeee', endColorstr = '#cccccc', GradientType = 0);
230 | background-image: linear-gradient(top, #cccccc 0%, #eeeeee 60%);
231 | }
232 |
233 | .select2-container .select2-choice .select2-arrow b {
234 | display: block;
235 | width: 100%;
236 | height: 100%;
237 | background: url('select2.png') no-repeat 0 1px;
238 | }
239 |
240 | .select2-search {
241 | display: inline-block;
242 | width: 100%;
243 | min-height: 26px;
244 | margin: 0;
245 | padding-left: 4px;
246 | padding-right: 4px;
247 |
248 | position: relative;
249 | z-index: 10000;
250 |
251 | white-space: nowrap;
252 | }
253 |
254 | .select2-search input {
255 | width: 100%;
256 | height: auto !important;
257 | min-height: 26px;
258 | padding: 4px 20px 4px 5px;
259 | margin: 0;
260 |
261 | outline: 0;
262 | font-family: sans-serif;
263 | font-size: 1em;
264 |
265 | border: 1px solid #aaa;
266 | -webkit-border-radius: 0;
267 | -moz-border-radius: 0;
268 | border-radius: 0;
269 |
270 | -webkit-box-shadow: none;
271 | -moz-box-shadow: none;
272 | box-shadow: none;
273 |
274 | background: #fff url('select2.png') no-repeat 100% -22px;
275 | background: url('select2.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee));
276 | background: url('select2.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%);
277 | background: url('select2.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%);
278 | background: url('select2.png') no-repeat 100% -22px, -o-linear-gradient(bottom, white 85%, #eeeeee 99%);
279 | background: url('select2.png') no-repeat 100% -22px, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%);
280 | background: url('select2.png') no-repeat 100% -22px, linear-gradient(top, #ffffff 85%, #eeeeee 99%);
281 | }
282 |
283 | .select2-drop.select2-drop-above .select2-search input {
284 | margin-top: 4px;
285 | }
286 |
287 | .select2-search input.select2-active {
288 | background: #fff url('select2-spinner.gif') no-repeat 100%;
289 | background: url('select2-spinner.gif') no-repeat 100%, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee));
290 | background: url('select2-spinner.gif') no-repeat 100%, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%);
291 | background: url('select2-spinner.gif') no-repeat 100%, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%);
292 | background: url('select2-spinner.gif') no-repeat 100%, -o-linear-gradient(bottom, white 85%, #eeeeee 99%);
293 | background: url('select2-spinner.gif') no-repeat 100%, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%);
294 | background: url('select2-spinner.gif') no-repeat 100%, linear-gradient(top, #ffffff 85%, #eeeeee 99%);
295 | }
296 |
297 | .select2-container-active .select2-choice,
298 | .select2-container-active .select2-choices {
299 | border: 1px solid #75890c;
300 | outline: none;
301 |
302 | -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3);
303 | -moz-box-shadow: 0 0 5px rgba(0,0,0,.3);
304 | box-shadow: 0 0 5px rgba(0,0,0,.3);
305 | }
306 |
307 | .select2-dropdown-open .select2-choice {
308 | border-bottom-color: transparent;
309 | -webkit-box-shadow: 0 1px 0 #fff inset;
310 | -moz-box-shadow: 0 1px 0 #fff inset;
311 | box-shadow: 0 1px 0 #fff inset;
312 |
313 | -webkit-border-bottom-left-radius: 0;
314 | -moz-border-radius-bottomleft: 0;
315 | border-bottom-left-radius: 0;
316 |
317 | -webkit-border-bottom-right-radius: 0;
318 | -moz-border-radius-bottomright: 0;
319 | border-bottom-right-radius: 0;
320 |
321 | background-color: #eee;
322 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, white), color-stop(0.5, #eeeeee));
323 | background-image: -webkit-linear-gradient(center bottom, white 0%, #eeeeee 50%);
324 | background-image: -moz-linear-gradient(center bottom, white 0%, #eeeeee 50%);
325 | background-image: -o-linear-gradient(bottom, white 0%, #eeeeee 50%);
326 | background-image: -ms-linear-gradient(top, #ffffff 0%,#eeeeee 50%);
327 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#ffffff',GradientType=0 );
328 | background-image: linear-gradient(top, #ffffff 0%,#eeeeee 50%);
329 | }
330 |
331 | .select2-dropdown-open.select2-drop-above .select2-choice,
332 | .select2-dropdown-open.select2-drop-above .select2-choices {
333 | border: 1px solid #75890c;
334 | border-top-color: transparent;
335 |
336 | background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, white), color-stop(0.5, #eeeeee));
337 | background-image: -webkit-linear-gradient(center top, white 0%, #eeeeee 50%);
338 | background-image: -moz-linear-gradient(center top, white 0%, #eeeeee 50%);
339 | background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%);
340 | background-image: -ms-linear-gradient(bottom, #ffffff 0%,#eeeeee 50%);
341 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#ffffff',GradientType=0 );
342 | background-image: linear-gradient(bottom, #ffffff 0%,#eeeeee 50%);
343 | }
344 |
345 | .select2-dropdown-open .select2-choice .select2-arrow {
346 | background: transparent;
347 | border-left: none;
348 | filter: none;
349 | }
350 | .select2-dropdown-open .select2-choice .select2-arrow b {
351 | background-position: -18px 1px;
352 | }
353 |
354 | /* results */
355 | .select2-results {
356 | max-height: 200px;
357 | padding: 0 0 0 4px;
358 | margin: 4px 4px 4px 0;
359 | position: relative;
360 | overflow-x: hidden;
361 | overflow-y: auto;
362 | -webkit-tap-highlight-color: rgba(0,0,0,0);
363 | }
364 |
365 | .select2-results ul.select2-result-sub {
366 | margin: 0;
367 | padding-left: 0;
368 | }
369 |
370 | .select2-results ul.select2-result-sub > li .select2-result-label { padding-left: 20px }
371 | .select2-results ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 40px }
372 | .select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 60px }
373 | .select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 80px }
374 | .select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 100px }
375 | .select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 110px }
376 | .select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 120px }
377 |
378 | .select2-results li {
379 | list-style: none;
380 | display: list-item;
381 | background-image: none;
382 | }
383 |
384 | .select2-results li.select2-result-with-children > .select2-result-label {
385 | font-weight: bold;
386 | }
387 |
388 | .select2-results .select2-result-label {
389 | padding: 3px 7px 4px;
390 | margin: 0;
391 | cursor: pointer;
392 |
393 | min-height: 1em;
394 |
395 | -webkit-touch-callout: none;
396 | -webkit-user-select: none;
397 | -khtml-user-select: none;
398 | -moz-user-select: none;
399 | -ms-user-select: none;
400 | user-select: none;
401 | }
402 |
403 | .select2-results .select2-highlighted {
404 | background: #75890c;
405 | color: #fff;
406 | }
407 |
408 | .select2-results li em {
409 | background: #feffde;
410 | font-style: normal;
411 | }
412 |
413 | .select2-results .select2-highlighted em {
414 | background: transparent;
415 | }
416 |
417 | .select2-results .select2-highlighted ul {
418 | background: white;
419 | color: #000;
420 | }
421 |
422 |
423 | .select2-results .select2-no-results,
424 | .select2-results .select2-searching,
425 | .select2-results .select2-selection-limit {
426 | background: #f4f4f4;
427 | display: list-item;
428 | }
429 |
430 | /*
431 | disabled look for disabled choices in the results dropdown
432 | */
433 | .select2-results .select2-disabled.select2-highlighted {
434 | color: #666;
435 | background: #f4f4f4;
436 | display: list-item;
437 | cursor: default;
438 | }
439 | .select2-results .select2-disabled {
440 | background: #f4f4f4;
441 | display: list-item;
442 | cursor: default;
443 | }
444 |
445 | .select2-results .select2-selected {
446 | display: none;
447 | }
448 |
449 | .select2-more-results.select2-active {
450 | background: #f4f4f4 url('select2-spinner.gif') no-repeat 100%;
451 | }
452 |
453 | .select2-more-results {
454 | background: #f4f4f4;
455 | display: list-item;
456 | }
457 |
458 | /* disabled styles */
459 |
460 | .select2-container.select2-container-disabled .select2-choice {
461 | background-color: #f4f4f4;
462 | background-image: none;
463 | border: 1px solid #ddd;
464 | cursor: default;
465 | }
466 |
467 | .select2-container.select2-container-disabled .select2-choice .select2-arrow {
468 | background-color: #f4f4f4;
469 | background-image: none;
470 | border-left: 0;
471 | }
472 |
473 | .select2-container.select2-container-disabled .select2-choice abbr {
474 | display: none;
475 | }
476 |
477 |
478 | /* multiselect */
479 |
480 | .select2-container-multi .select2-choices {
481 | height: auto !important;
482 | height: 1%;
483 | margin: 0;
484 | padding: 0;
485 | position: relative;
486 |
487 | border: 1px solid #aaa;
488 | cursor: text;
489 | overflow: hidden;
490 |
491 | background-color: #fff;
492 | background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eeeeee), color-stop(15%, #ffffff));
493 | background-image: -webkit-linear-gradient(top, #eeeeee 1%, #ffffff 15%);
494 | background-image: -moz-linear-gradient(top, #eeeeee 1%, #ffffff 15%);
495 | background-image: -o-linear-gradient(top, #eeeeee 1%, #ffffff 15%);
496 | background-image: -ms-linear-gradient(top, #eeeeee 1%, #ffffff 15%);
497 | background-image: linear-gradient(top, #eeeeee 1%, #ffffff 15%);
498 | }
499 |
500 | .select2-locked {
501 | padding: 3px 5px 3px 5px !important;
502 | }
503 |
504 | .select2-container-multi .select2-choices {
505 | min-height: 26px;
506 | }
507 |
508 | .select2-container-multi.select2-container-active .select2-choices {
509 | border: 1px solid #75890c;
510 | outline: none;
511 |
512 | -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3);
513 | -moz-box-shadow: 0 0 5px rgba(0,0,0,.3);
514 | box-shadow: 0 0 5px rgba(0,0,0,.3);
515 | }
516 | .select2-container-multi .select2-choices li {
517 | float: left;
518 | list-style: none;
519 | }
520 | .select2-container-multi .select2-choices .select2-search-field {
521 | margin: 0;
522 | padding: 0;
523 | white-space: nowrap;
524 | }
525 |
526 | .select2-container-multi .select2-choices .select2-search-field input {
527 | padding: 5px;
528 | margin: 1px 0;
529 |
530 | font-family: sans-serif;
531 | font-size: 100%;
532 | color: #666;
533 | outline: 0;
534 | border: 0;
535 | -webkit-box-shadow: none;
536 | -moz-box-shadow: none;
537 | box-shadow: none;
538 | background: transparent !important;
539 | }
540 |
541 | .select2-container-multi .select2-choices .select2-search-field input.select2-active {
542 | background: #fff url('select2-spinner.gif') no-repeat 100% !important;
543 | }
544 |
545 | .select2-default {
546 | color: #999 !important;
547 | }
548 |
549 | .select2-container-multi .select2-choices .select2-search-choice {
550 | padding: 3px 5px 3px 18px;
551 | margin: 3px 0 3px 5px;
552 | position: relative;
553 |
554 | line-height: 13px;
555 | color: #333;
556 | cursor: default;
557 | border: 1px solid #aaaaaa;
558 |
559 | -webkit-border-radius: 3px;
560 | -moz-border-radius: 3px;
561 | border-radius: 3px;
562 |
563 | -webkit-box-shadow: 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05);
564 | -moz-box-shadow: 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05);
565 | box-shadow: 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05);
566 |
567 | -webkit-background-clip: padding-box;
568 | -moz-background-clip: padding;
569 | background-clip: padding-box;
570 |
571 | -webkit-touch-callout: none;
572 | -webkit-user-select: none;
573 | -khtml-user-select: none;
574 | -moz-user-select: none;
575 | -ms-user-select: none;
576 | user-select: none;
577 |
578 | background-color: #e4e4e4;
579 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#f4f4f4', GradientType=0 );
580 | background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eeeeee));
581 | background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
582 | background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
583 | background-image: -o-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
584 | background-image: -ms-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
585 | background-image: linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%);
586 | }
587 | .select2-container-multi .select2-choices .select2-search-choice .select2-chosen {
588 | cursor: default;
589 | }
590 | .select2-container-multi .select2-choices .select2-search-choice-focus {
591 | background: #d4d4d4;
592 | }
593 |
594 | .select2-search-choice-close {
595 | display: block;
596 | width: 12px;
597 | height: 13px;
598 | position: absolute;
599 | right: 3px;
600 | top: 4px;
601 |
602 | font-size: 1px;
603 | outline: none;
604 | background: url('select2.png') right top no-repeat;
605 | }
606 |
607 | .select2-container-multi .select2-search-choice-close {
608 | left: 3px;
609 | }
610 |
611 | .select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover {
612 | background-position: right -11px;
613 | }
614 | .select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close {
615 | background-position: right -11px;
616 | }
617 |
618 | /* disabled styles */
619 | .select2-container-multi.select2-container-disabled .select2-choices{
620 | background-color: #f4f4f4;
621 | background-image: none;
622 | border: 1px solid #ddd;
623 | cursor: default;
624 | }
625 |
626 | .select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice {
627 | padding: 3px 5px 3px 5px;
628 | border: 1px solid #ddd;
629 | background-image: none;
630 | background-color: #f4f4f4;
631 | }
632 |
633 | .select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close { display: none;
634 | background:none;
635 | }
636 | /* end multiselect */
637 |
638 |
639 | .select2-result-selectable .select2-match,
640 | .select2-result-unselectable .select2-match {
641 | text-decoration: underline;
642 | }
643 |
644 | .select2-offscreen, .select2-offscreen:focus {
645 | clip: rect(0 0 0 0);
646 | width: 1px;
647 | height: 1px;
648 | border: 0;
649 | margin: 0;
650 | padding: 0;
651 | overflow: hidden;
652 | position: absolute;
653 | outline: 0;
654 | left: 0px;
655 | }
656 |
657 | .select2-display-none {
658 | display: none;
659 | }
660 |
661 | .select2-measure-scrollbar {
662 | position: absolute;
663 | top: -10000px;
664 | left: -10000px;
665 | width: 100px;
666 | height: 100px;
667 | overflow: scroll;
668 | }
669 | /* Retina-ize icons */
670 |
671 | @media only screen and (-webkit-min-device-pixel-ratio: 1.5), only screen and (min-resolution: 144dpi) {
672 | .select2-search input, .select2-search-choice-close, .select2-container .select2-choice abbr, .select2-container .select2-choice .select2-arrow b {
673 | background-image: url('select2x2.png') !important;
674 | background-repeat: no-repeat !important;
675 | background-size: 60px 40px !important;
676 | }
677 | .select2-search input {
678 | background-position: 100% -21px !important;
679 | }
680 | }
681 |
--------------------------------------------------------------------------------
/public/assets/fonts/digital-7-webfont.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
--------------------------------------------------------------------------------