diff --git a/.gitignore b/.gitignore index 63854d9..0745d26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -*swp -*swo -.* +*.swp +*.swo +__pycache__/ +*.pyc +.env diff --git a/requirements.txt b/requirements.txt index 584c1f6..22a3c82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -RPi.GPIO -flask \ No newline at end of file +flask>=3.1,<4.0 +gpiozero>=2.0,<3.0 diff --git a/static/styles/custom.css b/static/styles/custom.css index c713f2a..de314dd 100644 --- a/static/styles/custom.css +++ b/static/styles/custom.css @@ -27,3 +27,29 @@ #green-off{ background: #006000; } + +.closing-status { + background: #fff3cd; + border: 1px solid #ffc107; + color: #856404; + padding: 10px 15px; + border-radius: 4px; + margin: 10px 0; + font-size: 0.95em; +} + +.closing-form { + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid #ddd; +} + +.closing-form input { + margin-right: 8px; + margin-bottom: 8px; +} + +.locked-label { + color: #999; + font-style: italic; +} diff --git a/templates/main.html b/templates/main.html index 259c8bc..240cde3 100644 --- a/templates/main.html +++ b/templates/main.html @@ -1,39 +1,498 @@ + - Traffic Light - - - - - + + + Traffic Light + + -
-
-
-

Traffic Light

-
-
- {% for name, values in pins.items() %} -
-
-
-
-
- {% if values[1] == 'on' %} - Turn Off - {% else %} - Turn On - {% endif %} -
-
- {% endfor %} - {% if message %} -
-

Output:

-

{{message}}

-
- {% endif %} + + + + + + +
+

Traffic Light

+
+ +
+
+ + +
+ +
+
+
+
+
+ + +
+ +
+

Light Controls

+
+ Red +
+ + + +
+
+
+ Yellow +
+ + + +
+
+
+ Green +
+ + + +
+
+
+ + +
+

Party Mode

+
+ + +
+
+ + +
+

Keyholder Controls

+
+ +
+
+ + +
+ +
+
+ + +
+ + + diff --git a/test_scripts/off.py b/test_scripts/off.py index 2f537da..c8f473e 100755 --- a/test_scripts/off.py +++ b/test_scripts/off.py @@ -1,40 +1,14 @@ -#!/usr/bin/python2 -print('script start') -import RPi.GPIO as GPIO -import time -GPIO.setmode(GPIO.BCM) -GPIO.setwarnings(False) +#!/usr/bin/env python3 +"""Turn all traffic lights off. -seq = ['red', 'yellow', 'green', 'yellow',] +Usage: + python3 off.py + GPIOZERO_PIN_FACTORY=mock python3 off.py # Run without hardware +""" -pins = { - 2 : {'name' : 'red', 'state' : GPIO.LOW}, - 3 : {'name' : 'green', 'state' : GPIO.LOW}, - 4 : {'name' : 'yellow', 'state' : GPIO.LOW} - } +from gpiozero import TrafficLights -for pin in pins: - GPIO.setup(pin, GPIO.OUT) - GPIO.output(pin, GPIO.LOW) - -#color = input('Color? ') -def light_off(): - for i in pins: - GPIO.output(i, GPIO.LOW) - -def light_on(color): - for i in pins: - if pins[i]['name'] == color: - GPIO.output(i, GPIO.HIGH) - time.sleep(.5) - GPIO.output(i, GPIO.LOW) - -def sequence(seq): - while True: - for i in seq: - light_on(i) - -#light_on(color) -#sequence(seq) -light_off() -print('script end') +traffic_lights = TrafficLights(red=17, yellow=27, green=22) +traffic_lights.off() +traffic_lights.close() +print("All lights turned off.") diff --git a/test_scripts/test.py b/test_scripts/test.py index 45a8547..f2f2513 100755 --- a/test_scripts/test.py +++ b/test_scripts/test.py @@ -1,39 +1,43 @@ -#!/usr/bin/python2 -print('script start') -import RPi.GPIO as GPIO -import time -GPIO.setmode(GPIO.BCM) -GPIO.setwarnings(False) - -seq = ['red', 'yellow', 'green', 'yellow',] - -pins = { - 2 : {'name' : 'red', 'state' : GPIO.LOW}, - 3 : {'name' : 'green', 'state' : GPIO.LOW}, - 4 : {'name' : 'yellow', 'state' : GPIO.LOW} - } - -for pin in pins: - GPIO.setup(pin, GPIO.OUT) - GPIO.output(pin, GPIO.LOW) - -#color = input('Color? ') -def light_off(): - for i in pins: - GPIO.output(i, GPIO.LOW) - -def light_on(color): - for i in pins: - if pins[i]['name'] == color: - GPIO.output(i, GPIO.HIGH) - time.sleep(.19) - GPIO.output(i, GPIO.LOW) - -def sequence(seq): - while True: - for i in seq: - light_on(i) - -#light_on(color) -sequence(seq) -print('script end') +#!/usr/bin/env python3 +"""Test script: run a traffic light sequence continuously. + +Press Ctrl+C to stop. GPIO pins are cleaned up automatically by gpiozero. + +Usage: + python3 test.py + GPIOZERO_PIN_FACTORY=mock python3 test.py # Run without hardware +""" + +from time import sleep + +from gpiozero import TrafficLights + +SEQUENCE: list[str] = ["red", "yellow", "green", "yellow"] +STEP_DURATION: float = 0.19 + +traffic_lights = TrafficLights(red=17, yellow=27, green=22) + + +def light_on(color: str) -> None: + """Flash a single light briefly.""" + led = getattr(traffic_lights, color) + led.on() + sleep(STEP_DURATION) + led.off() + + +def run_sequence(sequence: list[str]) -> None: + """Run the light sequence in a loop until interrupted.""" + print("Starting traffic light sequence (Ctrl+C to stop)...") + try: + while True: + for color in sequence: + light_on(color) + except KeyboardInterrupt: + print("\nSequence stopped.") + finally: + traffic_lights.off() + + +if __name__ == "__main__": + run_sequence(SEQUENCE) diff --git a/traffic.py b/traffic.py index 419b09e..61b3500 100755 --- a/traffic.py +++ b/traffic.py @@ -1,136 +1,421 @@ -#!/usr/bin/python3 -import RPi.GPIO as GPIO -from flask import Flask, render_template, request +#!/usr/bin/env python3 +"""Traffic light controller web application. + +A Flask-based web interface for controlling a Raspberry Pi traffic light +with red, yellow, and green LEDs via gpiozero. +""" + +import atexit +import hmac +import logging +import os +import threading +from datetime import datetime, timedelta +from random import choice from time import sleep -from random import randint, choice -app = Flask(__name__) +from flask import Flask, jsonify, render_template, request, session +from gpiozero import TrafficLights + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +RED_PIN: int = int(os.environ.get("TRAFFIC_RED_PIN", "17")) +YELLOW_PIN: int = int(os.environ.get("TRAFFIC_YELLOW_PIN", "27")) +GREEN_PIN: int = int(os.environ.get("TRAFFIC_GREEN_PIN", "22")) +HOST: str = os.environ.get("TRAFFIC_HOST", "0.0.0.0") +PORT: int = int(os.environ.get("TRAFFIC_PORT", "80")) +BLINK_ON_TIME: float = float(os.environ.get("TRAFFIC_BLINK_ON", "0.25")) +BLINK_OFF_TIME: float = float(os.environ.get("TRAFFIC_BLINK_OFF", "0.25")) +PARTY_DEFAULT_ITERATIONS: int = int(os.environ.get("TRAFFIC_PARTY_ITERATIONS", "5")) +PARTY_SINGLE_ITERATIONS: int = 19 +COUNTDOWN_STEP_DELAY: float = 1.0 +LIGHT_ORDER: tuple[str, ...] = ("red", "yellow", "green") +VALID_COLORS: set[str] = set(LIGHT_ORDER) +VALID_ACTIONS: set[str] = {"on", "off", "toggle"} +MAX_RAGER_ITERATIONS: int = 100 + +# Closing countdown configuration +KEYHOLDER_PASSWORD: str = os.environ.get("TRAFFIC_PASSWORD", "changeme") +CLOSING_WARN_MINUTES: int = 30 +CLOSING_FLASH_MINUTES: int = 10 +CLOSING_HOLD_MINUTES: int = 30 +FLASH_SPEED_START: float = 1.0 +FLASH_SPEED_END: float = 0.1 + +# Auth configuration +KEYHOLDER_PIN: str = os.environ.get( + "TRAFFIC_KEYHOLDER_PIN", + os.environ.get("TRAFFIC_PASSWORD", "1234"), +) +SECRET_KEY: str = os.environ.get("TRAFFIC_SECRET_KEY", "change-me-in-production") -GPIO.setmode(GPIO.BCM) +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("traffic") -# Create a dictionary called pins to store the pin number, name, and pin state: -# pins = { -# 17 : {'name' : 'red', 'state' : GPIO.LOW}, -# 27 : {'name' : 'yellow', 'state' : GPIO.LOW}, -# 22 : {'name' : 'green', 'state' : GPIO.LOW}, -# } +# --------------------------------------------------------------------------- +# Hardware +# --------------------------------------------------------------------------- +traffic_lights = TrafficLights(red=RED_PIN, yellow=YELLOW_PIN, green=GREEN_PIN) +atexit.register(traffic_lights.close) +logger.info( + "Traffic lights initialized: red=%d, yellow=%d, green=%d", + RED_PIN, YELLOW_PIN, GREEN_PIN, +) -pins = { - 'red' : [17, GPIO.LOW], - 'yellow' : [27, GPIO.LOW], - 'green' : [22, GPIO.LOW] + +# --------------------------------------------------------------------------- +# Closing countdown state +# --------------------------------------------------------------------------- +closing_state: dict = { + "active": False, + "close_time": None, + "phase": "normal", + "thread": None, + "cancel_event": None, } +# --------------------------------------------------------------------------- +# Lock state +# --------------------------------------------------------------------------- +lock_state: dict = {"locked": False, "locked_at": None} -# Set each pin as an output and make it low: -for pin, state in pins.values(): - # print(pin) - GPIO.setup(pin, GPIO.OUT) - GPIO.output(pin, GPIO.LOW) -def humanize_pin_state(pins): - for name, values in pins.items(): - pin_state = values[1] - if pin_state == GPIO.HIGH: - pins[name][1] = 'on' - else: - pins[name][1] = 'off' - return pins +def get_closing_info() -> dict: + """Return a template-friendly dict describing the current closing state.""" + if not closing_state["active"]: + return {"active": False, "phase": "normal", "minutes_remaining": None, "close_time_str": None} -@app.route("/") -def main(): - getPinState() - # Put the pin dictionary into the template data dictionary: - templateData = { - 'pins' : humanize_pin_state(pins) + now = datetime.now() + close_time = closing_state["close_time"] + remaining = (close_time - now).total_seconds() / 60.0 + return { + "active": True, + "phase": closing_state["phase"], + "minutes_remaining": max(0, int(remaining)), + "close_time_str": close_time.strftime("%-I:%M %p"), + } + + +def _run_closing_sequence() -> None: + """Background thread that drives the closing countdown phases.""" + cancel = closing_state["cancel_event"] + close_time = closing_state["close_time"] + warn_start = close_time - timedelta(minutes=CLOSING_WARN_MINUTES) + flash_start = close_time - timedelta(minutes=CLOSING_FLASH_MINUTES) + hold_end = close_time + timedelta(minutes=CLOSING_HOLD_MINUTES) + + def _cancelled() -> bool: + return cancel.is_set() + + def _sleep_until(target: datetime) -> bool: + """Sleep in 0.5s increments until target or cancellation. Returns True if cancelled.""" + while datetime.now() < target: + if _cancelled(): + return True + sleep(0.5) + return False + + try: + # Wait until warning phase begins + if _sleep_until(warn_start): + return + + # --- Warning phase: solid yellow --- + closing_state["phase"] = "warning" + logger.info("Closing sequence: warning phase (solid yellow)") + traffic_lights.green.off() + traffic_lights.red.off() + traffic_lights.yellow.on() + + if _sleep_until(flash_start): + return + + # --- Flashing phase: red blink, speed ramps up --- + closing_state["phase"] = "flashing" + logger.info("Closing sequence: flashing phase (red blink)") + traffic_lights.yellow.off() + + total_flash_seconds = CLOSING_FLASH_MINUTES * 60.0 + while datetime.now() < close_time: + if _cancelled(): + return + remaining = (close_time - datetime.now()).total_seconds() + progress = 1.0 - (remaining / total_flash_seconds) if total_flash_seconds > 0 else 1.0 + progress = max(0.0, min(1.0, progress)) + interval = FLASH_SPEED_START + (FLASH_SPEED_END - FLASH_SPEED_START) * progress + half = interval / 2.0 + traffic_lights.red.on() + sleep(half) + if _cancelled(): + return + traffic_lights.red.off() + sleep(half) + + # --- Closed phase: solid red --- + closing_state["phase"] = "closed" + logger.info("Closing sequence: closed phase (solid red)") + traffic_lights.red.on() + + if _sleep_until(hold_end): + return + + # --- Off phase: all off, sequence ends --- + closing_state["phase"] = "off" + logger.info("Closing sequence: off phase (all lights off)") + traffic_lights.off() + + finally: + if _cancelled(): + logger.info("Closing sequence cancelled") + closing_state["active"] = False + closing_state["phase"] = "normal" + closing_state["close_time"] = None + closing_state["thread"] = None + closing_state["cancel_event"] = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def get_light_states() -> dict[str, dict[str, int | str]]: + """Return current state of each light for template rendering.""" + states: dict[str, dict[str, int | str]] = {} + for color in LIGHT_ORDER: + led = getattr(traffic_lights, color) + states[color] = { + "pin": led.pin.number, + "state": "on" if led.is_lit else "off", } - # Pass the template data into the template main.html and return it to the user - return render_template('main.html', **templateData) - -# The function below is executed when someone requests a URL with the pin number and action in it: -# changePin is now color name -@app.route("/toggle//") -def action(color, action): - # Convert the pin from the URL into an integer: - changePin = pins[color][0] - # Get the device name for the pin being changed: - # deviceName = pins[changePin]['name'] - deviceName = color - # If the action part of the URL is "on," execute the code indented below: + return states + + +def blinky_blink(color: str) -> None: + """Blink a single LED once, blocking until complete.""" + led = getattr(traffic_lights, color) + led.blink(on_time=BLINK_ON_TIME, off_time=BLINK_OFF_TIME, n=1, background=False) + + +def count_down() -> None: + """Flash each light in sequence: red, yellow, green.""" + traffic_lights.off() + sleep(BLINK_ON_TIME) + for color in LIGHT_ORDER: + led = getattr(traffic_lights, color) + led.on() + sleep(COUNTDOWN_STEP_DELAY) + led.off() + + +def is_keyholder() -> bool: + """Check if the current session is authenticated as keyholder.""" + return session.get("keyholder", False) + + +def can_control_lights() -> tuple[bool, str | None]: + """Check if light control is currently allowed. + + Returns (allowed, reason) — reason is None when allowed. + """ + if lock_state["locked"] and not is_keyholder(): + return False, "System is locked by keyholder." + if closing_state["active"] and not is_keyholder(): + return False, "Closing in progress — lights are locked." + return True, None + + +# --------------------------------------------------------------------------- +# Flask application +# --------------------------------------------------------------------------- +app = Flask(__name__) +app.secret_key = SECRET_KEY + + +@app.route("/") +def main() -> str: + """Serve the dashboard HTML.""" + return render_template("main.html") + + +# --------------------------------------------------------------------------- +# API endpoints +# --------------------------------------------------------------------------- +@app.route("/api/status") +def api_status(): + """Full state for polling: lights, closing, lock, auth.""" + lights = get_light_states() + closing = get_closing_info() + return jsonify({ + "lights": {color: info["state"] for color, info in lights.items()}, + "closing": closing, + "lock": { + "locked": lock_state["locked"], + "locked_at": lock_state["locked_at"], + }, + "is_keyholder": is_keyholder(), + }) + + +@app.route("/api/auth/login", methods=["POST"]) +def api_login(): + """Authenticate with PIN, sets session.""" + data = request.get_json(silent=True) or {} + pin = data.get("pin", "") + + if hmac.compare_digest(str(pin), KEYHOLDER_PIN): + session["keyholder"] = True + logger.info("Keyholder authenticated") + return jsonify({"ok": True, "message": "Authenticated as keyholder."}) + + logger.warning("Failed keyholder login attempt") + return jsonify({"ok": False, "message": "Invalid PIN."}), 401 + + +@app.route("/api/auth/logout", methods=["POST"]) +def api_logout(): + """Clear keyholder session.""" + if not is_keyholder(): + return jsonify({"ok": False, "message": "Not authenticated."}), 401 + + session.pop("keyholder", None) + logger.info("Keyholder logged out") + return jsonify({"ok": True, "message": "Logged out."}) + + +@app.route("/api/light//", methods=["POST"]) +def api_light(color: str, action: str): + """Control a single traffic light (on/off/toggle).""" + allowed, reason = can_control_lights() + if not allowed: + return jsonify({"ok": False, "message": reason}), 403 + + if color not in VALID_COLORS: + return jsonify({"ok": False, "message": f"Unknown color: {color}"}), 404 + if action not in VALID_ACTIONS: + return jsonify({"ok": False, "message": f"Unknown action: {action}"}), 404 + + led = getattr(traffic_lights, color) + if action == "on": - # Set the pin high: - GPIO.output(changePin, GPIO.HIGH) - # Save the status message to be passed into the template: - message = "Turned " + deviceName + " on." - if action == "off": - GPIO.output(changePin, GPIO.LOW) - message = "Turned " + deviceName + " off." - if action == "toggle": - # Read the pin and set it to whatever it isn't (that is, toggle it): - GPIO.output(changePin, not GPIO.input(changePin)) - message = "Toggled " + deviceName + "." - if action == "party": - for i in range(1,20): - blinkyBlink(changePin) - message = "Partied Hard" - - getPinState() - - # Along with the pin dictionary, put the message into the template data dictionary: - templateData = { - 'message' : message, - 'pins' : humanize_pin_state(pins) - } + led.on() + message = f"Turned {color} on." + elif action == "off": + led.off() + message = f"Turned {color} off." + elif action == "toggle": + led.toggle() + message = f"Toggled {color}." + + logger.info(message) + return jsonify({"ok": True, "message": message}) - return render_template('main.html', **templateData) -@app.route("/rager/") -@app.route("/rager/") -def partyHard(iterations = 5): #Add untested partyHard mode - countDown() - sleep(1) +@app.route("/api/rager", methods=["POST"]) +def api_rager(): + """Party mode with configurable iterations.""" + allowed, reason = can_control_lights() + if not allowed: + return jsonify({"ok": False, "message": reason}), 403 + + data = request.get_json(silent=True) or {} try: - iterations = int(iterations) - except: - iterations = 5 - for i in range(iterations): - # pin = choice(list(pins.values())) - pin = choice(list(pins.values()))[0] - blinkyBlink(pin) - templateData = { - # 'message' : message, - 'pins' : humanize_pin_state(pins) - } - return render_template('main.html', **templateData) - -def getPinState(): - # For each pin, read the pin state and store it in the pins dictionary: - for pin in pins: - pins[pin][1] = GPIO.input(pins[pin][0]) - -def blinkyBlink(pin): - GPIO.output(pin, GPIO.HIGH) - sleep(0.25) - GPIO.output(pin, GPIO.LOW) - sleep(0.25) - -def goLow(): - for pin, state in pins.values(): - GPIO.output(pin, GPIO.LOW) - -def countDown(): - goLow() - sleep(.25) - for pin, state in pins.values(): - GPIO.output(pin, GPIO.HIGH) - sleep(1) - GPIO.output(pin, GPIO.LOW) - -# def rager(curr, prev): -# for i in range(1,randint(4,16)): -# curr = curr if curr != prev else choice(list(pins.keys())) #give it a second shot of being something else for a little more fun. -# blinkyBlink(curr) -# return curr + num_iterations = min(int(data.get("iterations", PARTY_DEFAULT_ITERATIONS)), MAX_RAGER_ITERATIONS) + if num_iterations < 1: + num_iterations = PARTY_DEFAULT_ITERATIONS + except (ValueError, TypeError): + num_iterations = PARTY_DEFAULT_ITERATIONS + + count_down() + sleep(COUNTDOWN_STEP_DELAY) + + colors = list(LIGHT_ORDER) + for _ in range(num_iterations): + blinky_blink(choice(colors)) + + logger.info("Rager completed: %d iterations", num_iterations) + return jsonify({"ok": True, "message": f"Partied hard ({num_iterations} iterations)."}) + + +@app.route("/api/lock", methods=["POST"]) +def api_lock(): + """Set lock state (keyholder only).""" + if not is_keyholder(): + return jsonify({"ok": False, "message": "Keyholder authentication required."}), 403 + + data = request.get_json(silent=True) or {} + locked = data.get("locked", True) + + lock_state["locked"] = bool(locked) + lock_state["locked_at"] = datetime.now().isoformat() if locked else None + + action = "locked" if locked else "unlocked" + logger.info("System %s by keyholder", action) + return jsonify({"ok": True, "message": f"System {action}."}) + + +@app.route("/api/close", methods=["POST"]) +def api_close(): + """Schedule a closing countdown (keyholder only).""" + if not is_keyholder(): + return jsonify({"ok": False, "message": "Keyholder authentication required."}), 403 + + if closing_state["active"]: + return jsonify({"ok": False, "message": "Closing already in progress."}), 409 + + data = request.get_json(silent=True) or {} + try: + minutes = int(data.get("minutes", 0)) + if minutes < 1: + raise ValueError + except (ValueError, TypeError): + return jsonify({"ok": False, "message": "Invalid number of minutes."}), 400 + + close_time = datetime.now() + timedelta(minutes=minutes) + + closing_state["close_time"] = close_time + closing_state["active"] = True + closing_state["phase"] = "normal" + closing_state["cancel_event"] = threading.Event() + + thread = threading.Thread(target=_run_closing_sequence, daemon=True) + closing_state["thread"] = thread + thread.start() + + logger.info("Closing scheduled in %d minutes (at %s)", minutes, close_time.strftime("%-I:%M %p")) + return jsonify({"ok": True, "message": f"Closing scheduled in {minutes} minutes."}) + + +@app.route("/api/cancel-close", methods=["POST"]) +def api_cancel_close(): + """Cancel an active closing countdown (keyholder only).""" + if not is_keyholder(): + return jsonify({"ok": False, "message": "Keyholder authentication required."}), 403 + + if not closing_state["active"]: + return jsonify({"ok": False, "message": "No closing in progress."}), 409 + + cancel_event = closing_state["cancel_event"] + thread = closing_state["thread"] + if cancel_event: + cancel_event.set() + if thread: + thread.join(timeout=5) + + # Reset lights to off after cancellation + traffic_lights.off() + logger.info("Closing cancelled by keyholder") + return jsonify({"ok": True, "message": "Closing cancelled."}) + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- if __name__ == "__main__": - app.run(host='0.0.0.0', port=80, debug=False) + logger.info("Starting traffic light server on %s:%d", HOST, PORT) + app.run(host=HOST, port=PORT, debug=False) diff --git a/traffic.service b/traffic.service index c0445f0..9698df2 100644 --- a/traffic.service +++ b/traffic.service @@ -1,10 +1,22 @@ [Unit] -Description=Traffic Light Service - +Description=Traffic Light Controller +After=network.target + [Service] -ExecStart=/home/pi/Trafficlight-2.0/traffic.py -StandardOutput=null - +Type=simple +User=pi +Group=gpio +WorkingDirectory=/home/pi/Trafficlight-2.0 +ExecStart=/usr/bin/python3 /home/pi/Trafficlight-2.0/traffic.py +Restart=on-failure +RestartSec=5 +AmbientCapabilities=CAP_NET_BIND_SERVICE +StandardOutput=journal +StandardError=journal +Environment=TRAFFIC_PORT=80 +Environment=TRAFFIC_KEYHOLDER_PIN=1234 +Environment=TRAFFIC_SECRET_KEY=change-me-in-production + [Install] WantedBy=multi-user.target Alias=trafficlight.service diff --git a/traffic.sh b/traffic.sh index 8a7b983..442b7a6 100755 --- a/traffic.sh +++ b/traffic.sh @@ -1,2 +1,4 @@ #!/bin/bash -sudo /home/pi/Trafficlight-2.0/traffic.py +# Quick launcher for manual testing. In production, use: +# sudo systemctl start traffic +exec python3 "$(dirname "$0")/traffic.py"