| @@ -8,6 +8,7 @@ class Dummy(Actor): | |||
| @cbpi.addon.actor.action("WOHOO") | |||
| def myaction(self): | |||
| print "WOOHOO" | |||
| pass | |||
| def on(self, power=100): | |||
| @@ -29,6 +30,7 @@ class MyController(KettleController): | |||
| def run(self): | |||
| while self.is_running(): | |||
| self.sleep(1) | |||
| @cbpi.addon.fermenter.controller() | |||
| @@ -38,6 +40,8 @@ class MyController2(FermenterController): | |||
| def run(self): | |||
| while self.is_running(): | |||
| print "HALLO" | |||
| self.get_target_temp() | |||
| self.sleep(1) | |||
| @cbpi.addon.core.initializer(order=200) | |||
| @@ -24,7 +24,7 @@ class Dummy(Sensor): | |||
| @cbpi.addon.sensor.action("WOHOO") | |||
| def myaction(self): | |||
| print "SENSOR ACTION HALLO!!!" | |||
| self.api.notify(headline="WOHOO", message="HALLO") | |||
| def execute(self): | |||
| while True: | |||
| @@ -32,7 +32,7 @@ class Dummy(Sensor): | |||
| self.update_value(int(self.text)) | |||
| except: | |||
| pass | |||
| self.api.sleep(1) | |||
| self.api.sleep(5) | |||
| @cbpi.addon.core.action(key="clear", label="Clear all Logs") | |||
| def woohoo(cbpi): | |||
| @@ -1,6 +1,7 @@ | |||
| from modules.core.basetypes import Step | |||
| from modules.core.core import cbpi | |||
| from modules.core.proptypes import Property | |||
| import time | |||
| @cbpi.addon.step.type("Dummy Step") | |||
| @@ -22,4 +23,223 @@ class Dummy(Step): | |||
| def reset(self): | |||
| self.stop_timer() | |||
| self.stop_timer() | |||
| # -*- coding: utf-8 -*- | |||
| @cbpi.addon.step.type("Dummy Step") | |||
| class MashStep(Step): | |||
| ''' | |||
| Just put the decorator @cbpi.step on top of a method | |||
| ''' | |||
| # Properties | |||
| temp = Property.Number("Temperature", configurable=True, description="Target Temperature of Mash Step") | |||
| kettle = Property.Kettle("Kettle", description="Kettle in which the mashing takes place") | |||
| timer = Property.Number("Timer in Minutes", configurable=True, description="Timer is started when the target temperature is reached") | |||
| def init(self): | |||
| ''' | |||
| Initialize Step. This method is called once at the beginning of the step | |||
| :return: | |||
| ''' | |||
| # set target tep | |||
| self.set_target_temp(self.temp, self.kettle) | |||
| @cbpi.addon.step.action("Start Timer") | |||
| def start(self): | |||
| ''' | |||
| Custom Action which can be execute form the brewing dashboard. | |||
| All method with decorator @cbpi.action("YOUR CUSTOM NAME") will be available in the user interface | |||
| :return: | |||
| ''' | |||
| if self.is_timer_finished() is None: | |||
| self.start_timer(int(self.timer) * 60) | |||
| def reset(self): | |||
| self.stop_timer() | |||
| self.set_target_temp(self.temp, self.kettle) | |||
| def finish(self): | |||
| self.set_target_temp(0, self.kettle) | |||
| def execute(self): | |||
| ''' | |||
| This method is execute in an interval | |||
| :return: | |||
| ''' | |||
| # Check if Target Temp is reached | |||
| if self.get_kettle_temp(self.kettle) >= float(self.temp): | |||
| # Check if Timer is Running | |||
| if self.is_timer_finished() is None: | |||
| self.start_timer(int(self.timer) * 60) | |||
| # Check if timer finished and go to next step | |||
| if self.is_timer_finished() == True: | |||
| self.notify("Mash Step Completed!", "Starting the next step", timeout=None) | |||
| self.next() | |||
| @cbpi.addon.step.type("MashInStep") | |||
| class MashInStep(Step): | |||
| ''' | |||
| Just put the decorator @cbpi.step on top of a method | |||
| ''' | |||
| # Properties | |||
| temp = Property.Number("Temperature", configurable=True, description="Target Temperature of Mash Step") | |||
| kettle = Property.Kettle("Kettle", description="Kettle in which the mashing takes place") | |||
| s = False | |||
| def init(self): | |||
| ''' | |||
| Initialize Step. This method is called once at the beginning of the step | |||
| :return: | |||
| ''' | |||
| # set target tep | |||
| self.s = False | |||
| self.set_target_temp(self.temp, self.kettle) | |||
| def execute(self): | |||
| ''' | |||
| This method is execute in an interval | |||
| :return: | |||
| ''' | |||
| # Check if Target Temp is reached | |||
| if self.get_kettle_temp(self.kettle) >= float(self.temp) and self.s is False: | |||
| self.s = True | |||
| self.notify("Step Temp Reached!", "Please press the next button to continue", timeout=None) | |||
| @cbpi.addon.step.type("MashInStep") | |||
| class ChilStep(Step): | |||
| timer = Property.Number("Timer in Minutes", configurable=True, default_value=0, description="Timer is started immediately") | |||
| @cbpi.addon.step.action("Start Timer") | |||
| def start(self): | |||
| if self.is_timer_finished() is None: | |||
| self.start_timer(int(self.timer) * 60) | |||
| def reset(self): | |||
| self.stop_timer() | |||
| def finish(self): | |||
| pass | |||
| def execute(self): | |||
| if self.is_timer_finished() is None: | |||
| self.start_timer(int(self.timer) * 60) | |||
| if self.is_timer_finished() == True: | |||
| self.next() | |||
| @cbpi.addon.step.type("MashInStep") | |||
| class PumpStep(Step): | |||
| pump = Property.Actor("Pump", description="Pump actor gets toogled") | |||
| timer = Property.Number("Timer in Minutes", configurable=True, default_value=0, description="Timer is started immediately") | |||
| @cbpi.addon.step.action("Start Timer") | |||
| def start(self): | |||
| if self.is_timer_finished() is None: | |||
| self.start_timer(int(self.timer) * 60) | |||
| def reset(self): | |||
| self.stop_timer() | |||
| def finish(self): | |||
| self.actor_off(int(self.pump)) | |||
| def init(self): | |||
| self.actor_on(int(self.pump)) | |||
| def execute(self): | |||
| if self.is_timer_finished() is None: | |||
| self.start_timer(int(self.timer) * 60) | |||
| if self.is_timer_finished() == True: | |||
| self.next() | |||
| @cbpi.addon.step.type("MashInStep") | |||
| class BoilStep(Step): | |||
| ''' | |||
| Just put the decorator @cbpi.step on top of a method | |||
| ''' | |||
| # Properties | |||
| temp = Property.Number("Temperature", configurable=True, default_value=100, description="Target temperature for boiling") | |||
| kettle = Property.Kettle("Kettle", description="Kettle in which the boiling step takes place") | |||
| timer = Property.Number("Timer in Minutes", configurable=True, default_value=90, description="Timer is started when target temperature is reached") | |||
| hop_1 = Property.Number("Hop 1 Addition", configurable=True, description="Fist Hop alert") | |||
| hop_1_added = Property.Number("",default_value=None) | |||
| hop_2 = Property.Number("Hop 2 Addition", configurable=True, description="Second Hop alert") | |||
| hop_2_added = Property.Number("", default_value=None) | |||
| hop_3 = Property.Number("Hop 3 Addition", configurable=True) | |||
| hop_3_added = Property.Number("", default_value=None, description="Third Hop alert") | |||
| hop_4 = Property.Number("Hop 4 Addition", configurable=True) | |||
| hop_4_added = Property.Number("", default_value=None, description="Fourth Hop alert") | |||
| hop_5 = Property.Number("Hop 5 Addition", configurable=True) | |||
| hop_5_added = Property.Number("", default_value=None, description="Fives Hop alert") | |||
| def init(self): | |||
| ''' | |||
| Initialize Step. This method is called once at the beginning of the step | |||
| :return: | |||
| ''' | |||
| # set target tep | |||
| self.set_target_temp(self.temp, self.kettle) | |||
| @cbpi.addon.step.action("Start Timer") | |||
| def start(self): | |||
| ''' | |||
| Custom Action which can be execute form the brewing dashboard. | |||
| All method with decorator @cbpi.action("YOUR CUSTOM NAME") will be available in the user interface | |||
| :return: | |||
| ''' | |||
| if self.is_timer_finished() is None: | |||
| self.start_timer(int(self.timer) * 60) | |||
| def reset(self): | |||
| self.stop_timer() | |||
| self.set_target_temp(self.temp, self.kettle) | |||
| def finish(self): | |||
| self.set_target_temp(0, self.kettle) | |||
| def check_hop_timer(self, number, value): | |||
| if self.__getattribute__("hop_%s_added" % number) is not True and time.time() > ( | |||
| self.timer_end - (int(self.timer) * 60 - int(value) * 60)): | |||
| self.__setattr__("hop_%s_added" % number, True) | |||
| self.notify("Hop Alert", "Please add Hop %s" % number, timeout=None) | |||
| def execute(self): | |||
| ''' | |||
| This method is execute in an interval | |||
| :return: | |||
| ''' | |||
| # Check if Target Temp is reached | |||
| if self.get_kettle_temp(self.kettle) >= float(self.temp): | |||
| # Check if Timer is Running | |||
| if self.is_timer_finished() is None: | |||
| self.start_timer(int(self.timer) * 60) | |||
| else: | |||
| self.check_hop_timer(1, self.hop_1) | |||
| self.check_hop_timer(2, self.hop_2) | |||
| self.check_hop_timer(3, self.hop_3) | |||
| self.check_hop_timer(4, self.hop_4) | |||
| self.check_hop_timer(5, self.hop_5) | |||
| # Check if timer finished and go to next step | |||
| if self.is_timer_finished() == True: | |||
| self.notify("Boil Step Completed!", "Starting the next step", timeout=None) | |||
| self.next() | |||
| @@ -227,6 +227,8 @@ class Timer(object): | |||
| return False | |||
| class Step(Base, Timer): | |||
| @@ -267,6 +269,25 @@ class Step(Base, Timer): | |||
| self.value = None | |||
| self.__dirty = False | |||
| def set_target_temp(self, temp, id=None): | |||
| temp = float(temp) | |||
| try: | |||
| if id is None: | |||
| self.api.emit("SET_TARGET_TEMP", id=self.kettle_id, temp=temp) | |||
| else: | |||
| self.api.emit("SET_TARGET_TEMP", id=id, temp=temp) | |||
| except Exception as e: | |||
| self.api.notify("Faild to set Target Temp", "", type="warning") | |||
| def get_kettle_temp(self, id=None): | |||
| id = int(id) | |||
| if id is None: | |||
| id = self.kettle_id | |||
| return self.api.sensor.get_value(int(self.api.cache.get("kettle").get(id).sensor)) | |||
| def is_dirty(self): | |||
| return self.__dirty | |||
| @@ -364,13 +364,22 @@ class CraftBeerPI(object): | |||
| method.callback = True | |||
| self.cache[key] = method | |||
| def get_config_parameter(self, key, default): | |||
| def get_config_parameter(self, key, default=None): | |||
| cfg = self.cache["config"].get(key) | |||
| if cfg is None: | |||
| return default | |||
| else: | |||
| return cfg.value | |||
| def set_config_parameter(self, name, value): | |||
| from modules.config import Config | |||
| with self._app.app_context(): | |||
| update_data = {"name": name, "value": value} | |||
| self.cache.get("config")[name].__dict__.update(**update_data) | |||
| c = Config.update(**update_data) | |||
| self.ws_emit("UPDATE_CONFIG", c) | |||
| def emit(self, key, **kwargs): | |||
| if self.eventbus.get(key) is not None: | |||
| @@ -131,4 +131,5 @@ class FermenterStep(DBModel): | |||
| def reset_all_steps(cls,id): | |||
| cur = get_db().cursor() | |||
| cur.execute("UPDATE %s SET state = 'I', start = NULL, end = NULL, timer_start = NULL WHERE fermenter_id = ?" % cls.__table_name__, (id,)) | |||
| get_db().commit() | |||
| get_db().commit() | |||
| @@ -106,6 +106,7 @@ class FermenterView(BaseView): | |||
| @route('/<int:id>/start', methods=['POST']) | |||
| def start_fermentation(self, id): | |||
| print "START" | |||
| active = None | |||
| for idx, s in enumerate(cbpi.cache.get(self.cache_key)[id].steps): | |||
| if s.state == 'A': | |||
| @@ -196,6 +197,7 @@ class FermenterView(BaseView): | |||
| return cbpi.cache["fermenter"].get(id) | |||
| def target_temp_reached(self,id, step): | |||
| print "TARGET TEMP REACHED" | |||
| timestamp = time.time() | |||
| days = step.days * 24 * 60 * 60 | |||
| @@ -210,19 +212,22 @@ class FermenterView(BaseView): | |||
| cbpi.ws_emit("UPDATE_FERMENTER", cbpi.cache.get(self.cache_key)[id]) | |||
| def check_step(self): | |||
| print "CHECK STEP" | |||
| print cbpi.cache["fermenter_task"] | |||
| for key, value in cbpi.cache["fermenter_task"].iteritems(): | |||
| print value | |||
| try: | |||
| fermenter = self.get_fermenter(key) | |||
| current_temp = current_temp = cbpi.get_sensor_value(int(fermenter.sensor)) | |||
| current_temp = current_temp = cbpi.sensor.get_value(int(fermenter.sensor)) | |||
| if value.timer_start is None: | |||
| print "TIMER IS NONE" | |||
| if value.direction == "H" : | |||
| print "TIMER WATING FOR HEATING" | |||
| if current_temp >= value.temp: | |||
| self.target_temp_reached(key,value) | |||
| else: | |||
| print "TIMER WATING FOR COILING" | |||
| if current_temp <= value.temp: | |||
| self.target_temp_reached(key, value) | |||
| else: | |||
| @@ -231,6 +236,7 @@ class FermenterView(BaseView): | |||
| else: | |||
| pass | |||
| except Exception as e: | |||
| print e | |||
| pass | |||
| @@ -217,6 +217,17 @@ class KettleView(BaseView): | |||
| self.api.brewing.toggle_automatic(id) | |||
| return ('', 204) | |||
| @cbpi.addon.core.listen("SET_TARGET_TEMP") | |||
| def set_target_temp(id, temp): | |||
| ''' | |||
| Change Taget Temp Event | |||
| :param id: kettle id | |||
| :param temp: target temp to set | |||
| :return: None | |||
| ''' | |||
| KettleView().postTargetTemp(id,temp) | |||
| @cbpi.addon.core.backgroundjob(key="read_target_temps", interval=5) | |||
| def read_target_temps(api): | |||
| """ | |||
| @@ -46,6 +46,7 @@ class PluginView(FlaskView): | |||
| response = requests.get("https://raw.githubusercontent.com/Manuel83/craftbeerpi-plugins/master/plugins.yaml") | |||
| self.api.cache["plugins"] = self.merge(yaml.load(response.text), self.api.cache["plugins"]) | |||
| for key, value in cbpi.cache["plugins"].iteritems(): | |||
| print key | |||
| value["installed"] = os.path.isdir("./plugins/%s/" % (key)) | |||
| return json.dumps(cbpi.cache["plugins"]) | |||
| @@ -0,0 +1,146 @@ | |||
| import os | |||
| import re | |||
| import time | |||
| import datetime | |||
| from flask import json, request, send_from_directory | |||
| from flask_classy import route, FlaskView | |||
| from modules.core.db import DBModel | |||
| from modules.core.baseview import BaseView | |||
| from modules.core.core import cbpi | |||
| from modules.database.dbmodel import Step | |||
| from yaml import Loader, Dumper | |||
| from yaml import load, dump | |||
| from modules.step import StepView | |||
| class RecipeBook(FlaskView): | |||
| @route('/load', methods=["POST"]) | |||
| def load(self): | |||
| data = request.json | |||
| recipe_name = data.get("name") | |||
| if re.match("^[A-Za-z0-9_-]*$", recipe_name) is None: | |||
| return ('Recipie Name contains not allowed characters', 500) | |||
| with open("./recipes/%s.json" % recipe_name) as json_data: | |||
| d = json.load(json_data) | |||
| Step.delete_all() | |||
| StepView().reset() | |||
| for s in d["steps"]: | |||
| Step.insert(**{"name": s.get("name"), "type": s.get("type"), "config": s.get("config")}) | |||
| self.api.ws_emit("UPDATE_ALL_STEPS", Step.get_all()) | |||
| self.api.notify(headline="Recipe %s loaded successfully" % recipe_name, message="") | |||
| return ('', 204) | |||
| @route('/download/<name>', methods=["GET"]) | |||
| def download(self, name): | |||
| """ | |||
| Download a log file by name | |||
| --- | |||
| tags: | |||
| - logs | |||
| parameters: | |||
| - in: path | |||
| name: file | |||
| schema: | |||
| type: string | |||
| required: true | |||
| description: filename | |||
| responses: | |||
| 200: | |||
| description: Log file downloaded | |||
| """ | |||
| file = "%s.json" % name | |||
| print file | |||
| if not self.check_filename(file): | |||
| return ('File Not Found111', 404) | |||
| return send_from_directory('../../recipes', file, as_attachment=True, attachment_filename=file) | |||
| def check_filename(self, name): | |||
| import re | |||
| print "CHECK" | |||
| pattern = re.compile('^([A-Za-z0-9-_])+.json$') | |||
| return True if pattern.match(name) else False | |||
| @route('/<name>', methods=["DELETE"]) | |||
| def remove(self, name): | |||
| recipe_name = name | |||
| if re.match("^[A-Za-z0-9_-]*$", recipe_name) is None: | |||
| return ('Recipie Name contains not allowed characters', 500) | |||
| filename = "./recipes/%s.json" % recipe_name | |||
| if os.path.isfile(filename) == True: | |||
| os.remove(filename) | |||
| self.api.notify(headline="Recipe %s deleted successfully" % recipe_name, message="") | |||
| return ('', 204) | |||
| else: | |||
| self.api.notify(headline="Faild to delete Recipe %s deleted" % recipe_name, message="") | |||
| return ('', 404) | |||
| return ('', 204) | |||
| @route('/', methods=["GET"]) | |||
| def get_all(self): | |||
| result = [] | |||
| for filename in os.listdir("./recipes"): | |||
| if filename.endswith(".json"): | |||
| result.append({"id":filename.split(".")[0], "name": filename.split(".")[0], "change_date": self._modification_date('./recipes/%s' % filename)}) | |||
| return json.dumps(result) | |||
| def _modification_date(self, filename): | |||
| t = os.path.getmtime(filename) | |||
| return datetime.datetime.fromtimestamp(t).strftime('%Y-%m-%d %H:%M:%S') | |||
| @route('/save', methods=["POST"]) | |||
| def save(self): | |||
| """ | |||
| Save Recepie | |||
| --- | |||
| tags: | |||
| - steps | |||
| responses: | |||
| 204: | |||
| description: Recipe saved | |||
| """ | |||
| recipe_name = self.api.get_config_parameter("brew_name") | |||
| if recipe_name is None or len(recipe_name) <= 0: | |||
| self.api.notify(headline="Please set brew name", message="Recipe not saved!", type="danger") | |||
| return ('Recipie Name contains not allowed characters', 500) | |||
| if re.match("^[\sA-Za-z0-9_-]*$", recipe_name) is None: | |||
| self.api.notify(headline="Only alphanummeric charaters are allowd for recipe name", message="", type="danger") | |||
| return ('Recipie Name contains not allowed characters', 500) | |||
| recipe_data = {"name": recipe_name, "steps": Step.get_all()} | |||
| file_name = recipe_name.replace(" ", "_") | |||
| with open('./recipes/%s.json' % file_name, 'w') as outfile: | |||
| json.dump(recipe_data, outfile, indent=4) | |||
| self.api.notify(headline="Recipe %s saved successfully" % recipe_name, message="") | |||
| return ('', 204) | |||
| @cbpi.addon.core.initializer(order=2000) | |||
| def init(cbpi): | |||
| RecipeBook.api = cbpi | |||
| RecipeBook.register(cbpi._app, route_base='/api/recipebook') | |||
| @@ -55,11 +55,12 @@ class BeerXMLImport(FlaskView): | |||
| if request.method == 'POST': | |||
| file = request.files['file'] | |||
| if file and self.allowed_file(file.filename): | |||
| file.save(os.path.join(self.api.app.config['UPLOAD_FOLDER'], "beer.xml")) | |||
| file.save(os.path.join(self.api.get_config_parameter('UPLOAD_FOLDER', "./upload"), "beer.xml")) | |||
| self.api.notify(headline="Upload Successful", message="The Beer XML file was uploaded succesfully") | |||
| return ('', 204) | |||
| return ('', 404) | |||
| except Exception as e: | |||
| print e | |||
| self.api.notify(headline="Upload Failed", message="Failed to upload Beer xml", type="danger") | |||
| return ('', 500) | |||
| @@ -88,12 +89,12 @@ class BeerXMLImport(FlaskView): | |||
| name = self.getRecipeName(id) | |||
| self.api.set_config_parameter("brew_name", name) | |||
| boil_time = self.getBoilTime(id) | |||
| mashstep_type = cbpi.get_config_parameter("step_mash", "MashStep") | |||
| mash_kettle = cbpi.get_config_parameter("step_mash_kettle", None) | |||
| mashstep_type = self.api.get_config_parameter("step_mash", "MashStep") | |||
| mash_kettle = self.api.get_config_parameter("step_mash_kettle", None) | |||
| boilstep_type = cbpi.get_config_parameter("step_boil", "BoilStep") | |||
| boil_kettle = cbpi.get_config_parameter("step_boil_kettle", None) | |||
| boil_temp = 100 if cbpi.get_config_parameter("unit", "C") == "C" else 212 | |||
| boilstep_type = self.api.get_config_parameter("step_boil", "BoilStep") | |||
| boil_kettle = self.api.get_config_parameter("step_boil_kettle", None) | |||
| boil_temp = 100 if self.api.get_config_parameter("unit", "C") == "C" else 212 | |||
| # READ KBH DATABASE | |||
| Step.delete_all() | |||
| @@ -108,7 +109,8 @@ class BeerXMLImport(FlaskView): | |||
| Step.insert(**{"name": "Boil", "type": boilstep_type, "config": {"kettle": boil_kettle, "temp": boil_temp, "timer": boil_time}}) | |||
| ## Add Whirlpool step | |||
| Step.insert(**{"name": "Whirlpool", "type": "ChilStep", "config": {"timer": 15}}) | |||
| self.api.emit("UPDATE_ALL_STEPS", Step.get_all()) | |||
| self.api.ws_emit("UPDATE_ALL_STEPS", Step.get_all()) | |||
| self.api.notify(headline="Recipe %s loaded successfully" % name, message="") | |||
| except Exception as e: | |||
| self.api.notify(headline="Failed to load Recipe", message=e.message, type="danger") | |||
| @@ -125,9 +127,6 @@ class BeerXMLImport(FlaskView): | |||
| return float(e.find('./RECIPE[%s]/BOIL_TIME' % (str(id))).text) | |||
| def getSteps(self, id): | |||
| e = xml.etree.ElementTree.parse(self.BEER_XML_FILE).getroot() | |||
| steps = [] | |||
| for e in e.findall('./RECIPE[%s]/MASH/MASH_STEPS/MASH_STEP' % (str(id))): | |||
| @@ -43,6 +43,7 @@ class StepView(BaseView): | |||
| """ | |||
| self.model.delete_all() | |||
| self.api.emit("ALL_BREWING_STEPS_DELETED") | |||
| self.api.set_config_parameter("brew_name", "") | |||
| cbpi.ws_emit("UPDATE_ALL_STEPS", self.model.get_all()) | |||
| return ('', 204) | |||
| @@ -0,0 +1,4 @@ | |||
| import re | |||
| recipe_name = "Manuel 8881881 18181 " | |||
| print re.match("^[\sA-Za-z0-9_-]*$", recipe_name) | |||
| @@ -29,5 +29,6 @@ from modules.base_plugins.steps import * | |||
| from modules.example_plugins.WebViewJquery import * | |||
| from modules.example_plugins.WebViewReactJs import * | |||
| from modules.example_plugins.swagger import * | |||
| from modules.recipe_book import * | |||
| cbpi.run() | |||
| @@ -0,0 +1,173 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <RECIPES> | |||
| <RECIPE> | |||
| <NAME>Pale Ale</NAME> | |||
| <VERSION>1</VERSION> | |||
| <TYPE>All Grain</TYPE> | |||
| <BREWER></BREWER> | |||
| <DISPLAY_BATCH_SIZE>6.5 gal</DISPLAY_BATCH_SIZE> | |||
| <DISPLAY_BOIL_SIZE>7.5 gal</DISPLAY_BOIL_SIZE> | |||
| <BATCH_SIZE>24.60517657</BATCH_SIZE> | |||
| <BOIL_SIZE>28.39058835</BOIL_SIZE> | |||
| <BOIL_TIME>60</BOIL_TIME> | |||
| <EFFICIENCY>63</EFFICIENCY> | |||
| <NOTES> | |||
| </NOTES> | |||
| <PRIMARY_TEMP>20</PRIMARY_TEMP> | |||
| <EST_COLOR>8.75</EST_COLOR> | |||
| <IBU>62.22</IBU> | |||
| <IBU_METHOD>Tinseth</IBU_METHOD> | |||
| <EST_ABV>4.88</EST_ABV> | |||
| <EST_OG>1.047 sg</EST_OG> | |||
| <EST_FG>1.01 sg</EST_FG> | |||
| <OG>1.047</OG> | |||
| <FG>1.01</FG> | |||
| <PRIMING_SUGAR_NAME></PRIMING_SUGAR_NAME> | |||
| <CARBONATION_USED></CARBONATION_USED> | |||
| <BF_PRIMING_METHOD></BF_PRIMING_METHOD> | |||
| <BF_PRIMING_AMOUNT></BF_PRIMING_AMOUNT> | |||
| <BF_CO2_LEVEL></BF_CO2_LEVEL> | |||
| <BF_CO2_UNIT>Volumes</BF_CO2_UNIT> | |||
| <URL></URL> | |||
| <BATCH_SIZE_MODE>f</BATCH_SIZE_MODE> | |||
| <YEAST_STARTER>false</YEAST_STARTER> | |||
| <NO_CHILL_EXTRA_MINUTES></NO_CHILL_EXTRA_MINUTES> | |||
| <STARTING_MASH_THICKNESS>3.33880656</STARTING_MASH_THICKNESS> | |||
| <PITCH_RATE>0.35</PITCH_RATE> | |||
| <FERMENTABLES> | |||
| <FERMENTABLE> | |||
| <NAME>Pale 2-Row</NAME> | |||
| <VERSION>1</VERSION> | |||
| <TYPE>Grain</TYPE> | |||
| <AMOUNT>4.08233133</AMOUNT> | |||
| <YIELD>80.43</YIELD> | |||
| <COLOR>1.8</COLOR> | |||
| <ADD_AFTER_BOIL>false</ADD_AFTER_BOIL> | |||
| <ORIGIN>American</ORIGIN> | |||
| </FERMENTABLE> | |||
| <FERMENTABLE> | |||
| <NAME>Caramel / Crystal 60L</NAME> | |||
| <VERSION>1</VERSION> | |||
| <TYPE>Grain</TYPE> | |||
| <AMOUNT>0.45359237</AMOUNT> | |||
| <YIELD>73.91</YIELD> | |||
| <COLOR>60</COLOR> | |||
| <ADD_AFTER_BOIL>false</ADD_AFTER_BOIL> | |||
| <ORIGIN>American</ORIGIN> | |||
| </FERMENTABLE> | |||
| <FERMENTABLE> | |||
| <NAME>Rye</NAME> | |||
| <VERSION>1</VERSION> | |||
| <TYPE>Grain</TYPE> | |||
| <AMOUNT>1.0205828325</AMOUNT> | |||
| <YIELD>82.61</YIELD> | |||
| <COLOR>3.5</COLOR> | |||
| <ADD_AFTER_BOIL>false</ADD_AFTER_BOIL> | |||
| <ORIGIN>American</ORIGIN> | |||
| </FERMENTABLE> | |||
| <FERMENTABLE> | |||
| <NAME>Flaked Wheat</NAME> | |||
| <VERSION>1</VERSION> | |||
| <TYPE>Adjunct</TYPE> | |||
| <AMOUNT>0.226796185</AMOUNT> | |||
| <YIELD>73.91</YIELD> | |||
| <COLOR>2</COLOR> | |||
| <ADD_AFTER_BOIL>false</ADD_AFTER_BOIL> | |||
| <ORIGIN></ORIGIN> | |||
| </FERMENTABLE> | |||
| <FERMENTABLE> | |||
| <NAME>Carapils</NAME> | |||
| <VERSION>1</VERSION> | |||
| <TYPE>Grain</TYPE> | |||
| <AMOUNT>0.226796185</AMOUNT> | |||
| <YIELD>76.09</YIELD> | |||
| <COLOR>1.3</COLOR> | |||
| <ADD_AFTER_BOIL>false</ADD_AFTER_BOIL> | |||
| <ORIGIN>German</ORIGIN> | |||
| </FERMENTABLE> | |||
| </FERMENTABLES> | |||
| <HOPS> | |||
| <HOP> | |||
| <NAME>El Dorado</NAME> | |||
| <VERSION>1</VERSION> | |||
| <ALPHA>15.7</ALPHA> | |||
| <AMOUNT>0.0283495231</AMOUNT> | |||
| <USE>First Wort</USE> | |||
| <USER_HOP_USE>First Wort</USER_HOP_USE> | |||
| <TIME>0</TIME> | |||
| <FORM>Leaf</FORM> | |||
| </HOP> | |||
| <HOP> | |||
| <NAME>El Dorado</NAME> | |||
| <VERSION>1</VERSION> | |||
| <ALPHA>15.7</ALPHA> | |||
| <AMOUNT>0.0283495231</AMOUNT> | |||
| <USE>Boil</USE> | |||
| <USER_HOP_USE>Boil</USER_HOP_USE> | |||
| <TIME>30</TIME> | |||
| <FORM>Leaf</FORM> | |||
| </HOP> | |||
| <HOP> | |||
| <NAME>El Dorado</NAME> | |||
| <VERSION>1</VERSION> | |||
| <ALPHA>15.7</ALPHA> | |||
| <AMOUNT>0.0283495231</AMOUNT> | |||
| <USE>Dry Hop</USE> | |||
| <USER_HOP_USE>Dry Hop</USER_HOP_USE> | |||
| <TIME>14400</TIME> | |||
| <FORM>Leaf</FORM> | |||
| </HOP> | |||
| </HOPS> | |||
| <MISCS/> | |||
| <MASH> | |||
| <NAME>Mash Steps</NAME> | |||
| <VERSION>1</VERSION> | |||
| <GRAIN_TEMP>20</GRAIN_TEMP> | |||
| <MASH_STEPS> | |||
| <MASH_STEP> | |||
| <NAME>STEP1</NAME> | |||
| <VERSION>1</VERSION> | |||
| <TYPE>Infusion</TYPE> | |||
| <STEP_TIME>60</STEP_TIME> | |||
| <INFUSE_AMOUNT>1.5141647136</INFUSE_AMOUNT> | |||
| <STEP_TEMP>65.555555555556</STEP_TEMP> | |||
| </MASH_STEP> | |||
| </MASH_STEPS> | |||
| </MASH> | |||
| <YEASTS> | |||
| <YEAST> | |||
| <NAME>California Ale Yeast WLP001</NAME> | |||
| <VERSION>1</VERSION> | |||
| <TYPE>Ale</TYPE> | |||
| <FORM>Liquid</FORM> | |||
| <AMOUNT>0.1</AMOUNT> | |||
| <PRODUCT_ID>WLP001</PRODUCT_ID> | |||
| <LABORATORY>White Labs</LABORATORY> | |||
| <ATTENUATION>76.5</ATTENUATION> | |||
| <FLOCCULATION>Medium</FLOCCULATION> | |||
| <MIN_TEMPERATURE>20</MIN_TEMPERATURE> | |||
| <MAX_TEMPERATURE>22.777777777778</MAX_TEMPERATURE> | |||
| </YEAST> | |||
| </YEASTS> | |||
| <WATERS/> | |||
| <STYLE> | |||
| <NAME>English IPA</NAME> | |||
| <VERSION>1</VERSION> | |||
| <CATEGORY>India Pale Ale (IPA)</CATEGORY> | |||
| <CATEGORY_NUMBER>14</CATEGORY_NUMBER> | |||
| <STYLE_LETTER>A</STYLE_LETTER> | |||
| <STYLE_GUIDE>BJCP</STYLE_GUIDE> | |||
| <TYPE>Ale</TYPE> | |||
| <OG_MIN>1.05</OG_MIN> | |||
| <OG_MAX>1.075</OG_MAX> | |||
| <FG_MIN>1.01</FG_MIN> | |||
| <FG_MAX>1.018</FG_MAX> | |||
| <ABV_MIN>5</ABV_MIN> | |||
| <ABV_MAX>7.5</ABV_MAX> | |||
| <IBU_MIN>40</IBU_MIN> | |||
| <IBU_MAX>60</IBU_MAX> | |||
| <COLOR_MIN>8</COLOR_MIN> | |||
| <COLOR_MAX>14</COLOR_MAX> | |||
| </STYLE> | |||
| </RECIPE> | |||
| </RECIPES> | |||