Dashboard › › Session ses_1fb44ee6
ses_1fb44ee68ffepFf0TJOwcqVtwvWe have this light-match service running on this machine. It goes into day/automation mode at 7am and stays there until sunset. I want to change this to 8am in the morning and till min(6:30pm, sunset)
Let me find the light-match service configuration first.
task_id: ses_1fb44cf51ffelBNe31xRpahh5U (for resuming to continue this task if needed) <task_result> Here is the complete inventory of all files related to the "light-match" service found on this system: --- ## Files Found (7 unique files) ### 1. Source Code | File | Type | |------|------| | `/home/byk/Code/light-match.py` | **Python source** -- Main application. A ~2600+ line daemon that matches indoor lighting to outdoor brightness using a Yoctopuce light sensor, Shelly Dimmer G3, and BLE LED strip. Includes a web UI on port 9030, solar position calculations, weather integration, BLE device control, and daemon management (start/stop/status/run). | | `/home/byk/Code/__pycache__/light-match.cpython-312.pyc` | **Compiled bytecache** -- Python 3.12 bytecode cache of the source file above. | ### 2. Systemd Service Units | File | Type | |------|------| | `/etc/systemd/system/light-match.service` | **Live systemd unit** (active on system) -- Runs `light-match.py run` via `uv` with ...
Now let me read the main source file to find the day/automation mode schedule logic.
Found 32 matches
/home/byk/Code/light-match.py:
Line 103: SHELLY_BLE_ADDRESS = "E4:B0:63:E7:2B:92" # Shelly Dimmer G3 (WiFi MAC + 2)
Line 109: NIGHT_EXIT_LUX = 20.0 # outdoor > this → switch to day mode (dimmer)
Line 113: # Automation schedule — outside these hours the dimmer and strip stay off.
Line 116: SCHEDULE_DAYS = {0, 1, 2, 3, 4} # Monday=0 .. Friday=4
Line 117: SCHEDULE_START_HOUR = 7 # 07:00 local time
Line 120: def is_within_schedule(local_dt: datetime.datetime) -> bool:
Line 121: """Return True if automation should be active based on day/time schedule."""
Line 122: return local_dt.weekday() in SCHEDULE_DAYS and local_dt.hour >= SCHEDULE_START_HOUR
Line 125: def _next_schedule_label(now: datetime.datetime) -> str:
Line 127: # If today is a scheduled day and we're just before start hour, it's today
Line 128: if now.weekday() in SCHEDULE_DAYS and now.hour < SCHEDULE_START_HOUR:
Line 130: # Otherwise find the next ...
Let me read the relevant section of the code around the schedule configuration and the sunset logic.
<path>/home/byk/Code/light-match.py</path>
<type>file</type>
<content>
109: NIGHT_EXIT_LUX = 20.0 # outdoor > this → switch to day mode (dimmer)
110: MODE_DEBOUNCE = 2 # consecutive polls confirming new mode before switching
111:
112:
113: # Automation schedule — outside these hours the dimmer and strip stay off.
114: # Weekday-only (Mon–Fri), starting at SCHEDULE_START_HOUR local time.
115: # No end hour — runs from SCHEDULE_START_HOUR until midnight.
116: SCHEDULE_DAYS = {0, 1, 2, 3, 4} # Monday=0 .. Friday=4
117: SCHEDULE_START_HOUR = 7 # 07:00 local time
118:
119:
120: def is_within_schedule(local_dt: datetime.datetime) -> bool:
121: """Return True if automation should be active based on day/time schedule."""
122: return local_dt.weekday() in SCHEDULE_DAYS and local_dt.hour >= SCHEDULE_START_HOUR
123:
124:
125: def _next_schedule_label(now: datetime.datetime) -> str:
126: """Human-readable label for when the next active window starts."""
127: # If today ...
Found 1 matches /home/byk/Code/light-match.py: Line 115: # No end hour — runs from SCHEDULE_START_HOUR until midnight.
Now let me check how sunset is detected and how the mode transition works, since the current code runs until midnight (no end hour) and you want it to end at min(6:30pm, sunset).
Found 55 matches /home/byk/Code/light-match.py: Line 108: NIGHT_ENTER_LUX = 5.0 # outdoor < this → switch to night mode (LED strip) Line 142: """Return (elevation°, azimuth°) of the sun.""" Line 152: sin_elev = math.sin(math.radians(lat)) * math.sin(math.radians(decl)) + math.cos( Line 155: elev = math.degrees(math.asin(max(-1.0, min(1.0, sin_elev)))) Line 157: cos_elev = math.cos(math.radians(elev)) Line 158: if cos_elev < 1e-6: Line 162: math.sin(math.radians(decl)) - math.sin(math.radians(lat)) * sin_elev Line 163: ) / (math.cos(math.radians(lat)) * cos_elev) Line 168: return elev, az Line 171: def estimate_outdoor_lux(elevation: float, cloud_pct: float) -> float: Line 172: """Estimate outdoor lux from solar elevation and cloud cover. Line 174: Clear-sky model attenuated by cloud cover. ...
Let me read the schedule check area and the main loop where the schedule is enforced to understand the full picture.
<path>/home/byk/Code/light-match.py</path> <type>file</type> <content> 1930: weather.update() 1931: 1932: # Solar position → outdoor estimate → indoor target 1933: elev, az = solar_position(LATITUDE, LONGITUDE, now_utc) 1934: outdoor = estimate_outdoor_lux(elev, weather.cloud_pct) 1935: target = target_indoor_lux(outdoor) 1936: 1937: # ── Schedule gate ────────────────────────────────────────── 1938: # Outside scheduled hours, turn everything off and sleep. 1939: scheduled = is_within_schedule(now_local) 1940: with ds.lock: 1941: ds.scheduled = scheduled 1942: 1943: if not scheduled: 1944: # Ensure dimmer is off 1945: if cached_bri is not None or ds.dimmer_on: 1946: dimmer.turn_off() 1947: cached_bri = None 1948: with ds.lock: 1949: ...
Now I have the full picture. Here's what needs to change:
SCHEDULE_START_HOUR = 7 → 8SCHEDULE_END_TIME of 18:30 (6:30 PM) and make is_within_schedule() also check min(18:30, sunset).Let me also check the solar position function to see how to compute sunset time.
<path>/home/byk/Code/light-match.py</path> <type>file</type> <content> 138: # ── Solar position ───────────────────────────────────────────────────────── 139: 140: 141: def solar_position(lat: float, lon: float, utc: datetime.datetime): 142: """Return (elevation°, azimuth°) of the sun.""" 143: doy = utc.timetuple().tm_yday 144: h = utc.hour + utc.minute / 60 + utc.second / 3600 145: 146: decl = 23.45 * math.sin(math.radians(360 / 365 * (doy - 81))) 147: B = math.radians(360 / 365 * (doy - 81)) 148: eot = 9.87 * math.sin(2 * B) - 7.53 * math.cos(B) - 1.5 * math.sin(B) 149: solar_noon = 12.0 - lon / 15.0 - eot / 60.0 150: ha = 15.0 * (h - solar_noon) 151: 152: sin_elev = math.sin(math.radians(lat)) * math.sin(math.radians(decl)) + math.cos( 153: math.radians(lat) 154: ) * math.cos(math.radians(decl)) * math.cos(math.radians(ha)) 155: elev = math.degrees(math.asin(max(-1.0, min(1.0, sin_elev)))) 156: 157: cos_elev = ...
Found 6 matches
/home/byk/Code/light-match.py:
Line 53: LATITUDE = 51.4249
Line 54: LONGITUDE = -0.1954
Line 403: f"?latitude={LATITUDE}&longitude={LONGITUDE}"
Line 1783: log(f" Location : {LATITUDE:.4f}, {LONGITUDE:.4f}")
Line 1840: LATITUDE, LONGITUDE, datetime.datetime.now(datetime.timezone.utc)
Line 1933: elev, az = solar_position(LATITUDE, LONGITUDE, now_utc)
Good. I have everything I need. The existing solar_position function can be used to compute sunset time. I'll:
SCHEDULE_START_HOUR from 7 to 8SCHEDULE_END_TIME constant for 18:30sunset_time() helper that finds when solar elevation crosses 0°is_within_schedule() to end at min(18:30, sunset)_next_schedule_label() to reflect the end time[
{
"content": "Change SCHEDULE_START_HOUR from 7 to 8 and add SCHEDULE_END_TIME",
"status": "in_progress",
"priority": "high"
},
{
"content": "Add sunset_time() helper function",
"status": "pending",
"priority": "high"
},
{
"content": "Update is_within_schedule() to use min(18:30, sunset) as end time",
"status": "pending",
"priority": "high"
},
{
"content": "Update _next_schedule_label() to reflect end time",
"status": "pending",
"priority": "medium"
},
{
"content": "Update comments to reflect new schedule",
"status": "pending",
"priority": "low"
}
]
Now let me make all the changes. First, the constants and comment:
Edit applied successfully.
Now let me update the log line that prints the schedule parameters at startup:
[
{
"content": "Change SCHEDULE_START_HOUR from 7 to 8 and add SCHEDULE_END_TIME",
"status": "completed",
"priority": "high"
},
{
"content": "Add sunset_time() helper function",
"status": "completed",
"priority": "high"
},
{
"content": "Update is_within_schedule() to use min(18:30, sunset) as end time",
"status": "completed",
"priority": "high"
},
{
"content": "Update _next_schedule_label() to reflect end time",
"status": "completed",
"priority": "medium"
},
{
"content": "Update comments to reflect new schedule",
"status": "completed",
"priority": "low"
},
{
"content": "Update startup log line showing schedule parameters",
"status": "in_progress",
"priority": "medium"
}
]
Found 11 matches
/home/byk/Code/light-match.py:
Line 114: # Weekday-only (Mon–Fri), from SCHEDULE_START_HOUR to min(SCHEDULE_END_TIME, sunset).
Line 115: SCHEDULE_DAYS = {0, 1, 2, 3, 4} # Monday=0 .. Friday=4
Line 116: SCHEDULE_START_HOUR = 8 # 08:00 local time
Line 148: if local_dt.hour < SCHEDULE_START_HOUR:
Line 156: if now.weekday() in SCHEDULE_DAYS and now.hour < SCHEDULE_START_HOUR:
Line 157: return f"{now.strftime('%a')} {SCHEDULE_START_HOUR:02d}:00"
Line 163: return f"{candidate.strftime('%a')} {SCHEDULE_START_HOUR:02d}:00"
Line 168: return f"{candidate.strftime('%a')} {SCHEDULE_START_HOUR:02d}:00"
Line 169: return f"Mon {SCHEDULE_START_HOUR:02d}:00" # fallback
Line 1180: # Schedule
Line 1971: # ── Schedule gate ──────────────────────────────────────────
<path>/home/byk/Code/light-match.py</path>
<type>file</type>
<content>
1780: ds.dimmer_user_bri = None
1781: ds.strip_user_off = False # let auto resume strip
1782: log(" [SWITCH] night + auto OFF -> automation ON, dimmer OFF")
1783: _wakeup()
1784:
1785: # Register with the web handler so Shelly webhook calls can reach it
1786: _WebHandler.on_switch_toggle = staticmethod(on_switch_toggle)
1787:
1788: def shutdown(sig, _frame):
1789: nonlocal running
1790: running = False
1791: _wakeup()
1792:
1793: def usr_strip_off(sig, _frame):
1794: # No lock — signal handlers must not acquire locks (deadlock risk).
1795: # CPython GIL makes bool assignment atomic.
1796: ds.strip_user_off = True
1797: _wakeup()
1798:
1799: def usr_strip_on(sig, _frame):
1800: ds.strip_user_off = False
1801: _wakeup()
1802:
1803: ...
<path>/home/byk/Code/light-match.py</path>
<type>file</type>
<content>
1810: # Start web UI server
1811: _start_web_server(ds, logbuf)
1812:
1813: # Detach physical switch from direct dimmer control — we handle it via webhooks
1814: dimmer.detach_input()
1815:
1816: log("light-match started")
1817: log(f" Location : {LATITUDE:.4f}, {LONGITUDE:.4f}")
1818: if isinstance(dimmer, ShellyDimmerBLE):
1819: log(f" Shelly : BLE {SHELLY_BLE_ADDRESS}")
1820: else:
1821: log(f" Shelly : HTTP {SHELLY_IP}")
1822: log(f" BLE strip : {BLE_ADDRESS} ({'ready' if led_strip else 'unavailable'})")
1823: log(
1824: f" Cycle : {CYCLE_INTERVAL}s (poll {POLL_INTERVAL}s) "
1825: f"steps: {STEP_NORMAL}/{STEP_LARGE}/{STEP_URGENT}% dead-band: +/-{DEADBAND_PCT:.0%} (min {DEADBAND_MIN_LUX}lx)"
1826: )
1827: log(f" Brightness : {MIN_BRIGHTNESS}% – {MAX_BRIGHTNESS}%")
1828: log(
1829: f" Mode ...
I see there's no startup log line specifically for the schedule, so it's fine. Let me also check the web UI status display to see if it shows schedule info:
<path>/home/byk/Code/light-match.py</path>
<type>file</type>
<content>
1175: self.effect_preview_prev_color: tuple[int, int, int] | None = None
1176: self.effect_preview_prev_bri: int | None = None
1177: # Automation control
1178: self.automation: bool = True # master automation toggle (physical switch)
1179: self.last_switch_toggle: float = 0.0 # monotonic time of last toggle (debounce)
1180: # Schedule
1181: self.scheduled: bool = True # whether automation is within schedule
1182: # Uptime
1183: self.started_at: float = time.monotonic()
1184:
1185: def to_dict(self) -> dict:
1186: with self.lock:
1187: uptime_s = int(time.monotonic() - self.started_at)
1188: h, rem = divmod(uptime_s, 3600)
1189: m, s = divmod(rem, 60)
1190: return {
1191: "mode": self.mode,
1192: "solar": {
1193: "elevation": ...
Oh I'm fine running till midnight, the min(18:30, sunset) part was for the switchover from day to night mode
Ah, I misunderstood. So the schedule (when automation is active) should still run from 8am until midnight — that part just changes the start hour. The min(18:30, sunset) applies to when the system should force a DAY → NIGHT mode transition, regardless of lux readings.
Let me revert the schedule end time changes and instead apply the logic to the mode switching. Let me re-read the current state after my edit, and the mode transition code:
<path>/home/byk/Code/light-match.py</path>
<type>file</type>
<content>
113: # Automation schedule — outside these hours the dimmer and strip stay off.
114: # Weekday-only (Mon–Fri), from SCHEDULE_START_HOUR to min(SCHEDULE_END_TIME, sunset).
115: SCHEDULE_DAYS = {0, 1, 2, 3, 4} # Monday=0 .. Friday=4
116: SCHEDULE_START_HOUR = 8 # 08:00 local time
117: SCHEDULE_END_TIME = datetime.time(18, 30) # 18:30 local time (hard cap)
118:
119:
120: def _sunset_time(local_dt: datetime.datetime) -> datetime.time:
121: """Approximate today's sunset (elevation crosses 0°) by scanning afternoon minutes."""
122: utc_offset = local_dt.utcoffset() or datetime.timedelta(0)
123: date = local_dt.date()
124: # Scan from noon to midnight in 1-minute steps; find last minute with elev >= 0
125: last_positive = datetime.time(18, 30) # fallback if no sunset found
126: for minute in range(720, 1440): # 12:00 → 23:59
127: local_naive = datetime.datetime.combine(date, ...
<path>/home/byk/Code/light-match.py</path> <type>file</type> <content> 2045: cutoff = now_mono - TREND_WINDOW 2046: target_history = [(t, v) for t, v in target_history if t >= cutoff] 2047: 2048: # Compute trend: how much has the target changed over the window? 2049: trend = 0.0 # negative = target is falling (getting darker) 2050: if len(target_history) >= 2: 2051: trend = target_history[-1][1] - target_history[0][1] 2052: 2053: # ── Mode determination (with debounce) ───────────────────── 2054: if outdoor < NIGHT_ENTER_LUX: 2055: night_counter = min(night_counter + 1, MODE_DEBOUNCE + 1) 2056: day_counter = 0 2057: elif outdoor > NIGHT_EXIT_LUX: 2058: day_counter = min(day_counter + 1, MODE_DEBOUNCE + 1) 2059: night_counter = 0 2060: else: 2061: # In the hysteresis band — don't ...
[
{
"content": "Change SCHEDULE_START_HOUR from 7 to 8",
"status": "completed",
"priority": "high"
},
{
"content": "Revert schedule end time changes (schedule should still run till midnight)",
"status": "in_progress",
"priority": "high"
},
{
"content": "Keep sunset_time() helper but repurpose for mode switching",
"status": "pending",
"priority": "high"
},
{
"content": "Add forced DAY→NIGHT transition at min(18:30, sunset) in mode determination",
"status": "pending",
"priority": "high"
}
]
Now let me revert the schedule functions back to the original (no end time), keeping only the start hour change to 8. Then I'll repurpose the sunset helper and add a forced night mode cutoff.
Edit applied successfully.
Now let me update the mode determination logic to force night mode after the cutoff:
[
{
"content": "Change SCHEDULE_START_HOUR from 7 to 8",
"status": "completed",
"priority": "high"
},
{
"content": "Revert schedule end time changes (schedule should still run till midnight)",
"status": "completed",
"priority": "high"
},
{
"content": "Keep sunset_time() helper but repurpose for mode switching",
"status": "completed",
"priority": "high"
},
{
"content": "Add forced DAY→NIGHT transition at min(18:30, sunset) in mode determination",
"status": "in_progress",
"priority": "high"
}
]
Edit applied successfully.
[
{
"content": "Change SCHEDULE_START_HOUR from 7 to 8",
"status": "completed",
"priority": "high"
},
{
"content": "Revert schedule end time changes (schedule should still run till midnight)",
"status": "completed",
"priority": "high"
},
{
"content": "Keep sunset_time() helper but repurpose for mode switching",
"status": "completed",
"priority": "high"
},
{
"content": "Add forced DAY→NIGHT transition at min(18:30, sunset) in mode determination",
"status": "completed",
"priority": "high"
}
]
All changes applied. Here's a summary:
Schedule start: SCHEDULE_START_HOUR changed from 7 to 8 (light-match.py:116)
Day→Night cutoff: New constant NIGHT_MODE_CUTOFF = 18:30 (light-match.py:119) and two new helpers:
_sunset_time() — scans afternoon minutes using the existing solar_position() to find when the sun crosses 0° elevationnight_mode_cutoff() — returns min(NIGHT_MODE_CUTOFF, sunset)Mode determination (around line 2057): After the cutoff time:
night_counter is immediately maxed out, forcing DAY→NIGHTday_counter is zeroed, and NIGHT→DAY transition is blocked by a not past_cutoff guardSo in winter when sunset is at e.g. 16:00, night mode kicks in at sunset. In summer when sunset is at e.g. 21:00, night mode is forced at 18:30. The automation schedule itself still runs 8am–midnight.
Note: _sunset_time() scans ~720 minutes once per poll cycle when evaluating the cutoff. Since the main loop polls every 30 seconds, this is negligible. If you'd prefer to cache the sunset time per day, I can add that.
Do you want me to restart the service?
yes please
sudo: The "no new privileges" flag is set, which prevents sudo from running as root. sudo: If sudo is running in a container, you may need to adjust the container configuration to disable the flag.
Can't use sudo from within this environment due to the "no new privileges" flag. You'll need to restart it manually:
sudo systemctl restart light-match
Or if you prefer to check the logs right after:
sudo systemctl restart light-match && journalctl -u light-match -f