#! /usr/bin/env python
#
# generate some heat!
#
# Wrap your RasPi in a closed box.  Get a usbrelay1 to control
# an incadescent light bulb in the box.  Heat to 45C.  profit.
#
# This code depends on the program 'usbrelay' to manage your usbrelay
# connected device.
#   Get it here:  git@github.com:darrylb123/usbrelay.git
#   Update the usbrelay_on/off variables below with your device ID
#
# This code depends on the program 'temper-poll' to read the box temp
# from an attached TEMPer device.
#   Get it here: git@github.com:padelt/temper-python.git
#
# ntpheatusb will use a lot less CPU than ntpheat, and more directly
# heats the XTAL rather than the CPU.
#
# Avoid the desire to decrease the wait time.  The relay clocks twice
# per cycle, and those cycles add up.  Minimize wear on your relay.
#
# Try the simple P controller (the default) before trying the PID controller.
# The PID controller may take some fiddling with the constants to get
# it working better than the simple P controller.
#
# More info on the blog post: https://blog.ntpsec.org/2017/03/21/More_Heat.html

from __future__ import print_function, division

import argparse
import atexit
import subprocess
import sys
import time

try:
    import ntp.util
except ImportError as e:
    sys.stderr.write("ntpheatusb: can't find Python NTP modules. "
                     "-- check PYTHONPATH.\n%s\n" % e)
    sys.exit(1)


def run_binary(cmd):
    """\
Run a binary
Return its output if good, None if bad
"""

    try:
        # sadly subprocess.check_output() is not in Python 2.6
        # so use Popen()
        # this throws an exception if not found
        proc = subprocess.Popen(cmd,
                                stdout=subprocess.PIPE,
                                stderr=subprocess.STDOUT,
                                universal_newlines=True)
        output = proc.communicate()[0].split("\n")

        if proc.returncode:
            # non-zero return code, fail
            print("Return %s" % proc.returncode)
            return None

    except ImportError as e:
        sys.stderr.write("Unable to run %s binary\n" % cmd[0])
        print(cmd)
        sys.stderr.write("%s\n" % e)
        return None

    return output


class PID:
    """\
Discrete PID control
"""

    def __init__(self, setpoint=0.0,
                 P=2.0, I=0.0, D=1.0,
                 Derivator=0, Integrator=0,
                 Integrator_max=100, Integrator_min=-100):

        self.Kp = P
        self.Ki = I
        self.Kd = D
        self.Derivator = Derivator
        self.Integrator = Integrator
        self.Integrator_max = Integrator_max
        self.Integrator_min = Integrator_min

        self.set_point = setpoint
        self.error = 0.0

    def __repr__(self):
        return "D_value=%s, I_value=%s" % (self.D_value, self.I_value)

    def setPoint(self, set_point):
        """
        Initialize the setpoint of PID
        """
        self.set_point = set_point

    def update(self, current_value):
        """
        Calculate PID output value for given reference input and feedback
        """

        self.error = self.set_point - current_value

        self.P_value = self.Kp * self.error
        self.D_value = self.Kd * (self.error - self.Derivator)
        self.Derivator = self.error

        self.Integrator = self.Integrator + self.error

        if self.Integrator > self.Integrator_max:
            self.Integrator = self.Integrator_max
        elif self.Integrator < self.Integrator_min:
            self.Integrator = self.Integrator_min

        self.I_value = self.Integrator * self.Ki

        PID = self.P_value + self.I_value + self.D_value

        return PID


# Work with argvars
parser = argparse.ArgumentParser(description="make heat with USB relay")
parser.add_argument('-p', '--pid',
                    action="store_true",
                    dest='pid',
                    help="Use PID controller instead of simple P controller.")
parser.add_argument('-s', '--step',
                    action="store_true",
                    dest='step',
                    help="Step up 1C every 2 hours for 20 hours, "
                         "then back down.")
parser.add_argument('-t', '--temp',
                    default=[45.0],
                    dest='target_temp',
                    help="Temperature to hold in C.  Default is 45.0C",
                    nargs=1,
                    type=float)
parser.add_argument('-w', '--wait',
                    default=[60],
                    dest='wait',
                    help="Set delay time in seconds, default is 60",
                    nargs=1,
                    type=float)
parser.add_argument('-v', '--verbose',
                    action="store_true",
                    dest='verbose',
                    help="be verbose")
parser.add_argument('-V', '--version',
                    action="version",
                    version="ntpheatusb %s" % ntp.util.stdversion())
args = parser.parse_args()

zone0 = '/sys/class/thermal/thermal_zone0/temp'
cnt = 0

temp_gate = args.target_temp[0]
start_temp_gate = temp_gate
period = float(args.wait[0])

# you will need to personalize these to your relay ID:
usbrelay_on = ['usbrelay', '959BI_1=1']
usbrelay_off = ['usbrelay', '959BI_1=0']

# turn off the usb relay on exit.  no need to cook..
atexit.register(run_binary, usbrelay_off)

# to adjust the PID variables
# set I and D to zero
#
# increase P until you get a small overshoot, and mostly damped response,
# to a large temp change
#
# then increase I until the persistent error goes away.
#
# if the temp oscillates then increase D
#
pid = PID(setpoint=temp_gate, P=35.0, I=10.0, D=10.0)

start_time = time.time()
step_time = start_time
step = 0

start_time = time.time()
step_time = start_time
step = 0

try:
    while True:
        if args.step:
            now = time.time()
            if 7200 < (now - step_time):
                # time to step
                step_time = now
                step += 1
                if 0 <= step:
                    # step up
                    temp_gate += 1.0
                else:
                    # step down
                    temp_gate -= 1.0
                if 9 < step:
                    step = -11
            pid.setPoint(temp_gate)

        # only one device can read the TEMPer at a time
        # collisions will happen, so retry a few times
        for attempt in range(0, 3):
            # grab the needed output
            fail = False
            output = run_binary(["temper-poll", "-c"])
            try:
                # make sure it is a temperature
                temp = float(output[0])
                break
            except ValueError:
                # bad data, try aagin
                fail = True
                if args.verbose:
                    print("temper read failed: %s" % output)

        if fail:
            # give up
            print("temper fatal error")
            sys.exit(1)

        # the +20 is to create an 80/20 band around the setpoint
        p_val = pid.update(temp) + 20
        p_val1 = p_val
        if p_val > 100:
            p_val1 = 100
        elif p_val < 0:
            p_val1 = 0

        if temp > temp_gate:
            perc_t = 0
        elif temp < (temp_gate - 3):
            perc_t = 100
        else:
            perc_t = ((temp_gate - temp) / 3) * 100

        if args.pid:
            # use PID controller
            perc = p_val1
        else:
            # use P controller
            perc = perc_t

        if perc > 0:
            output = run_binary(usbrelay_on)
            time_on = period * (perc / 100)
            time.sleep(time_on)
        else:
            time_on = 0

        time_off = period - time_on
        output = run_binary(usbrelay_off)

        if args.verbose:
            print("Temp %s, perc %.2f, p_val %s/%s"
                  % (temp, perc_t, p_val, p_val1))
            print("on %s, off %s" % (time_on, time_off))
            print(pid)

        if 0 < time_off:
            time.sleep(time_off)

except KeyboardInterrupt:
    print("\nCaught ^C")
    run_binary(usbrelay_off)
    sys.exit(1)
    # can't fall through ???

except IOError:
    # exception catcher
    # turn off the heat!
    run_binary(usbrelay_off)
    print("\nCaught exception, exiting\n")
    sys.exit(1)
