#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
#
# Copyright (C) 2023 Quico Augustijn
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
#
# Route advisor steering for Euro Truck Simulator 2
#
# Monitor the red line of the in-game route advisor and steer with the mouse
# controls to keep the truck on road.

import time
import numpy as np
from mss import mss
from pynput import mouse

# Region of interest (roi) of the screen
#
# Preferably, half the 'width' parameter and the 'left' parameter added together
# should be the center position of the blue triangle on the route advisor and
# this should also be the center of your region of interest.
# A higher 'width' will give a greater field-of-view, but will be less
# performant.
# The defaults were determined when the game was in fullscreen.
roi_top = 850
roi_left = 1625
roi_width = 128
roi_height = 1

# Maximum amount of allowed colored pixels
#
# Whenever the amount of detected colored pixels exceeds this value, declare the
# route guidance as unreliable and stop taking action.
error_max = roi_width * 0.75

# Horizontal position of the center of the road (blue triangle on the route
# advisor)
#
# This should be the center of the region of interest.  If you have changed the
# region of interest parameters so that this is not the case, set the position
# here manually.
center_static = roi_width / 2 - 0.5
#center_static = 63.5

# PID parameters:
# To calculate how much steering is needed, a PID controller is used.
# Kp (Proportional control): Constant that specifies how much the controller
#                            should respond to the error value.
# Ki (Integral control): Constant that specifies how much the controller should
#                        respond to the error value related to past iterations.
# Kd (Derivative control): Constant that specifies how much the controller
#                          should respond to changes in error value.
Kp = float(1 / 4)
Ki = float(0)
Kd = float(1 / 5)

# Iteration speed:
# How fast the software should iterate and adjust the steering controls.  A
# higher number means shorter iteration sleep time.
iteration_speed = 75

# Color range
#
# Color values used to determine whether a pixel is colored or not.  You can
# adjust these colors to your liking, but the default reddish color should be
# fine.  Colors range from 0 to 255
red_min = 200
red_max = 255
green_min = 0
green_max = 35
blue_min = 0
blue_max = 35

# Maximum amount of characters to print on one line
print_max_length = 64

# Character to print at the end of a printed line
print_end_char = '\r'

# Print text that overwrites the last printed line
def print_line(string):
    rest = print_max_length - len(string)
    print(string[:print_max_length], ' ' * rest, end=print_end_char)

# Clamp a value between a given limit
def clamp(value, limit):
    if value > limit:
        return limit
    elif value < -limit:
        return -limit
    else:
        return value

# Function that decides if a pixel is colored
def is_pixel_colored(red, green, blue):
    return red >= red_min and red <= red_max and \
           green >= green_min and green <= green_max and \
           blue >= blue_min and blue <= blue_max

# Grab an instance of mss (for taking screenshots)
sct = mss()

# Grab an instance of the mouse controls
mouse = mouse.Controller()

# Create region of interest dictionary
roi = {"top": roi_top, "left": roi_left, "width": roi_width, "height": roi_height}

# Initialize error variables
error = None
old_error = None

# Interval (sleep) time to use
interval_time = (1 / iteration_speed)

# Variables for the PID controller
proportional = integral = derivative = 0

# Run for as long as we're allowed to live
while(True):
    # Grab the region of interest of the screen
    screen = sct.grab(roi)
    img = np.array(screen)

    # The image should be only one row in height
    row = img[0]

    # Get the length (amount of pixels horizontally)
    length = len(img[0])

    # Set the initial pixel information
    first_pixel = length # First colored pixel
    last_pixel = 0 # Last colored pixel
    found_first = False # If the first colored pixel is found
    found_last = False # If the last colored pixel is found

    # Iterate over each pixel in the row
    for x in range(length):
        # Current pixel
        pixel = row[x];

        # Get each RGB value of this pixel
        red = pixel[2]
        green = pixel[1]
        blue = pixel[0]

        # Only save this position if it is the very first colored pixel
        if x < first_pixel and is_pixel_colored(red, green, blue):
            first_pixel = x
            found_first = True

        # Only save this position if it is the very last colored pixel
        if x > last_pixel and is_pixel_colored(red, green, blue):
            last_pixel = x
            found_last = True

    # Only respond when colored pixels were found
    if not found_first or not found_last:
        print_line("Route out of sight")
        proportional = integral = derivative = 0
    else:
        # Calculate the width of the colored area
        width = last_pixel - first_pixel

        # Calculate the center of the colored area
        center_position = first_pixel + float(width) / 2

        if width < error_max:
            # Calculate the error value
            error = center_position - center_static
        else:
            # Do not use the error value
            print_line("Route detection unreliable")
            error = None

        # Do not respond on first iteration (change in error is not known yet)
        if error != None and old_error != None:
            # Calculate the difference in error compared to the previous iteration
            change = error - old_error

            # Proportional control
            proportional = error

            # Integral control
            integral = integral + error * interval_time
            integral = clamp(integral, roi_width / 2)

            # Derivative control
            derivative = change / interval_time

            # Calculate the output
            output = Kp * proportional + Ki * integral + Kd * derivative
            output = clamp(output, roi_width / 2)

            # Now move the mouse
            mouse.move(output, 0) # change in y is 0

            # Print status
            txt = "P {:.2f}".format(proportional) + "  " \
                  "I {:.2f}".format(integral) + "  " \
                  "D {:.2f}".format(derivative) + "  " \
                  "Offset {:.2f}".format(error) + "  " \
                  "Change {:.2f}".format(change)
            print_line(txt)

        # Record the current error for the next iteration
        old_error = error

    # Give our actions a little time to take effect
    time.sleep(interval_time)