Skip to content

Commit 845f354

Browse files
Merge pull request #87 from PortableProgrammer/dev
Modular architecture with abstract targets and enhanced ICS support
2 parents 8b4907c + e706201 commit 845f354

File tree

19 files changed

+1001
-122
lines changed

19 files changed

+1001
-122
lines changed

.github/workflows/docker-image.yml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,19 @@ jobs:
1414
steps:
1515
-
1616
name: Checkout
17-
uses: actions/checkout@v3
17+
uses: actions/checkout@v4
1818
-
1919
name: Prepare
2020
id: prepare
2121
run: |
2222
DOCKER_IMAGE=portableprogrammer/status-light
23-
DOCKER_PLATFORMS=linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
23+
DOCKER_PLATFORMS=linux/amd64,linux/arm/v7,linux/arm64
2424
VERSION=edge
2525
2626
if [[ $GITHUB_REF == refs/tags/* ]]; then
2727
VERSION=${GITHUB_REF#refs/tags/v}
2828
fi
29-
29+
3030
TAGS="--tag ${DOCKER_IMAGE}:${VERSION}"
3131
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}(\.[0-9]{1,3})?$ ]]; then
3232
TAGS="$TAGS --tag ${DOCKER_IMAGE}:latest"
@@ -37,10 +37,13 @@ jobs:
3737
echo "buildx_args=--platform ${DOCKER_PLATFORMS} --build-arg VERSION=${VERSION} --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') --build-arg VCS_REF=${GITHUB_SHA::8} ${TAGS} --file ./Dockerfiles/Dockerfile ./" >> "$GITHUB_ENV"
3838
-
3939
name: Set up QEMU
40-
uses: docker/setup-qemu-action@v2
40+
uses: docker/setup-qemu-action@v3
4141
-
4242
name: Set up Docker Buildx
43-
uses: docker/setup-buildx-action@v2
43+
uses: docker/setup-buildx-action@v3
44+
with:
45+
# Enable Docker layer caching
46+
buildkitd-flags: --debug
4447
-
4548
name: Docker Buildx (build)
4649
run: |

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ __pycache__
22
*.pyc
33
.vscode/
44
.devcontainer/
5+
.claude/
56
build/
67
dist/
78
status-light/__pycache__

CLAUDE.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Status-Light monitors user status across collaboration platforms (Webex, Slack) and calendars (Office 365, Google Calendar) to display the "busiest" status on a Tuya RGB LED smart bulb. The application runs as a continuous polling loop inside a Docker container.
8+
9+
## Running the Application
10+
11+
**Local execution:**
12+
```bash
13+
python -u status-light/status-light.py
14+
```
15+
16+
**Docker (preferred):**
17+
```bash
18+
docker run -e SOURCES=webex,office365 -e WEBEX_PERSONID=... portableprogrammer/status-light
19+
```
20+
21+
**Build Docker image locally:**
22+
```bash
23+
docker build -f Dockerfiles/Dockerfile -t status-light .
24+
```
25+
26+
There is no formal test suite, linter configuration, or build system. Development testing is done manually through Docker.
27+
28+
## Architecture
29+
30+
### Core Application Flow
31+
32+
`status-light/status-light.py` is the entry point containing the `StatusLight` class which:
33+
1. Validates environment variables and initializes configured sources
34+
2. Runs a continuous polling loop (configurable via `SLEEP_SECONDS`)
35+
3. Queries each enabled source for current status
36+
4. Applies status precedence rules to determine the "busiest" status
37+
5. Updates the Tuya smart light color accordingly
38+
6. Handles graceful shutdown via signal handlers (SIGHUP, SIGINT, SIGQUIT, SIGTERM)
39+
40+
### Status Sources
41+
42+
**Collaboration sources** (`sources/collaboration/`):
43+
- `webex.py` - Webex Teams presence API
44+
- `slack.py` - Slack presence with custom status emoji/text parsing
45+
46+
**Calendar sources** (`sources/calendar/`):
47+
- `office365.py` - Microsoft Graph API free/busy
48+
- `google.py` - Google Calendar API free/busy
49+
- `ics.py` - ICS/iCalendar file support (RFC 5545 compliant, uses `icalendar` + `recurring-ical-events` for proper timezone and recurring event handling)
50+
51+
### Status Precedence
52+
53+
Status determination follows a strict hierarchy:
54+
1. **Source priority:** Collaboration always wins over Calendar (except UNKNOWN/OFF)
55+
2. **Within collaboration:** Webex > Slack
56+
3. **Within calendar:** Office 365 > Google > ICS
57+
4. **Status priority:** Busy > Tentative/Scheduled > Available > Off
58+
59+
### Key Utilities
60+
61+
- `utility/enum.py` - Status enumerations (CollaborationStatus, CalendarStatus, Color)
62+
- `utility/env.py` - Environment variable parsing and validation
63+
- `utility/util.py` - Helper functions including `get_env_or_secret()` for Docker secrets
64+
- `utility/precedence.py` - Status precedence selection logic (selects winning status from multiple collaboration and calendar sources)
65+
66+
### Output Targets
67+
68+
All targets implement the `LightTarget` abstract interface defined in `targets/base.py`:
69+
70+
- `targets/base.py` - Abstract base class defining the light target interface (`set_color()`, `turn_off()`)
71+
- `targets/tuya.py` - Tuya smart bulbs (locked to COLOR mode, handles retry logic and DPS format conversion)
72+
- `targets/virtual.py` - Virtual light for testing (logs status changes, no hardware required)
73+
74+
## Configuration
75+
76+
All configuration is via environment variables (no config files). Key categories:
77+
78+
- **Sources:** `SOURCES` (comma-separated: webex, slack, office365, google, ics)
79+
- **Target:** `TARGET` (tuya or virtual; default: tuya)
80+
- **Authentication:** Platform-specific tokens/IDs (e.g., `WEBEX_PERSONID`, `SLACK_BOT_TOKEN`, `O365_APPID`, `ICS_URL`)
81+
- **Colors:** `AVAILABLE_COLOR`, `SCHEDULED_COLOR`, `BUSY_COLOR` (predefined names or 24-bit hex)
82+
- **Light:** `LIGHT_BRIGHTNESS` (0-100 percentage; default: 50; auto-detects legacy 0-255 format)
83+
- **Device:** `TUYA_DEVICE` (JSON with protocol, deviceid, ip, localkey; required only when TARGET=tuya)
84+
- **Behavior:** `SLEEP_SECONDS`, `CALENDAR_LOOKAHEAD`, `LOGLEVEL`, `ACTIVE_DAYS`, `ACTIVE_HOURS_*`
85+
86+
Secrets support `*_FILE` variants for Docker secrets integration.
87+
88+
## CI/CD
89+
90+
GitHub Actions (`.github/workflows/docker-image.yml`) builds multi-platform Docker images:
91+
- Platforms: linux/amd64, linux/arm/v6, linux/arm/v7, linux/arm64
92+
- Triggers: PRs to main (build only), pushes with `v*` tags (build + push to Docker Hub)
93+
- Published to: `portableprogrammer/status-light`
94+
95+
## Documentation Files
96+
97+
Keep these files in sync with code changes:
98+
- `README.md` - User-facing documentation for environment variables, setup, and usage
99+
- `CLAUDE.md` - Developer guidance for Claude Code (this file)
100+
101+
Update when:
102+
- New or modified environment variables
103+
- New dependencies in `requirements.txt`
104+
- Architecture changes (new sources, targets, utilities)
105+
- Configuration options

Dockerfiles/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ WORKDIR /usr/src/status-light
77
# Install dependencies
88
COPY ./requirements.txt ./
99
RUN apt-get update \
10-
&& apt-get install -y gcc \
10+
&& apt-get install -y gcc libffi-dev \
1111
&& rm -rf /var/lib/apt/lists/* \
1212
&& pip install -r requirements.txt \
13-
&& apt-get purge -y --auto-remove gcc
13+
&& apt-get purge -y --auto-remove gcc libffi-dev
1414

1515
# Copy the project
1616
COPY ./status-light .

README.md

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ services:
4040
image: portableprogrammer/status-light:latest
4141
environment:
4242
- "SOURCES=Webex,Office365"
43+
- "TARGET=tuya"
4344
- "AVAILABLE_COLOR=green"
4445
- "SCHEDULED_COLOR=orange"
4546
- "BUSY_COLOR=red"
@@ -48,14 +49,17 @@ services:
4849
- "BUSY_STATUS=call,donotdisturb,meeting,presenting,pending"
4950
- "OFF_STATUS=inactive,outofoffice,free,unknown"
5051
- 'TUYA_DEVICE={ "protocol": "3.3", "deviceid": "xxx", "ip": "yyy", "localkey": "zzz" }'
51-
- "TUYA_BRIGHTNESS=128"
52+
- "LIGHT_BRIGHTNESS=50"
5253
- "WEBEX_PERSONID=xxx"
5354
- "WEBEX_BOTID=xxx"
5455
- "O365_APPID=xxx"
5556
- "O365_APPSECRET=xxx"
5657
- "O365_TOKENSTORE=/data"
5758
- "GOOGLE_TOKENSTORE=/data"
5859
- "GOOGLE_CREDENTIALSTORE=/data"
60+
- "ICS_URL=https://example.com/calendar.ics"
61+
- "ICS_CACHESTORE=/data"
62+
- "ICS_CACHELIFETIME=30"
5963
- "SLACK_USER_ID=xxx"
6064
- "SLACK_BOT_TOKEN=xxx"
6165
- "SLACK_CUSTOM_AVAILABLE_STATUS=''"
@@ -125,12 +129,25 @@ secrets:
125129
- `slack`
126130
- `office365`
127131
- `google`
132+
- `ics`
128133
- Default value: `webex,office365`
129134

130135
If specificed, requires at least one of the available options. This will control which services Status-Light uses to determine overall availability status.
131136

132137
---
133138

139+
### `TARGET`
140+
141+
- *Optional*
142+
- Available values:
143+
- `tuya` - Physical Tuya smart bulb (requires `TUYA_DEVICE`)
144+
- `virtual` - Virtual light that logs status changes (for testing)
145+
- Default value: `tuya`
146+
147+
Specifies the output target for status display. Use `virtual` for testing without hardware.
148+
149+
---
150+
134151
### **Statuses**
135152

136153
- *Optional*
@@ -155,6 +172,10 @@ If specificed, requires at least one of the available options. This will control
155172
- Google
156173
- `free`
157174
- `busy`
175+
- ICS
176+
- `free`
177+
- `tentative`
178+
- `busy`
158179

159180
#### `AVAILABLE_STATUS`
160181

@@ -204,14 +225,17 @@ if webexStatus == const.Status.unknown or webexStatus in offStatus:
204225
# Fall through to Slack
205226
currentStatus = slackStatus
206227

207-
if (currentStatus in availableStatus or currentStatus in offStatus)
208-
and (officeStatus not in offStatus or googleStatus not in offStatus):
228+
if (currentStatus in availableStatus or currentStatus in offStatus)
229+
and (officeStatus not in offStatus or googleStatus not in offStatus
230+
or icsStatus not in offStatus):
209231

210-
# Office 365 currently takes precedence over Google
232+
# Office 365 currently takes precedence over Google, Google over ICS
211233
if (officeStatus != const.Status.unknown):
212234
currentStatus = officeStatus
213-
else:
235+
elif (googleStatus != const.Status.unknown):
214236
currentStatus = googleStatus
237+
else:
238+
currentStatus = icsStatus
215239

216240
if currentStatus in availableStatus:
217241
# Get availableColor
@@ -304,11 +328,13 @@ In the example above, the Slack custom status would match (since it is a case-in
304328

305329
### **Tuya**
306330

331+
**Note:** Tuya configuration is only required when [`TARGET`](#target) is set to `tuya` (the default). If using `TARGET=virtual`, you can skip this section.
332+
307333
#### `TUYA_DEVICE`
308334

309-
- *Required*
335+
- *Required if [`TARGET`](#target) is `tuya`*
310336

311-
Status-Light requires a [Tuya](https://www.tuya.com/) device, which are white-boxed and sold under many brand names. For example, the Tuya light working in the current environment is an [Above Lights](http://alabovelights.com/) [Smart Bulb 9W, model AL1](http://alabovelights.com/pd.jsp?id=17).
337+
Status-Light supports [Tuya](https://www.tuya.com/) devices, which are white-boxed and sold under many brand names. For example, the Tuya light working in the current environment is an [Above Lights](http://alabovelights.com/) [Smart Bulb 9W, model AL1](http://alabovelights.com/pd.jsp?id=17).
312338

313339
Status-Light uses the [tuyaface](https://github.com/TradeFace/tuyaface/) module for Tuya communication.
314340

@@ -326,13 +352,19 @@ Example `TUYA_DEVICE` value:
326352

327353
**Note:** Status-Light will accept an FQDN instead of IP, as long as the name can be resolved. Tuya devices will typically register themselves with the last 6 digits of the device ID, for example `ESP_xxxxxx.local`.
328354

329-
#### `TUYA_BRIGHTNESS`
355+
#### `LIGHT_BRIGHTNESS`
330356

331357
- *Optional*
332-
- Acceptable range: `32`-`255`
333-
- Default value: `128`
358+
- Acceptable range: `0`-`100` (percentage)
359+
- Default value: `50`
360+
361+
Set the brightness of your RGB light as a percentage (0-100%). Status-Light defaults to 50% brightness.
334362

335-
Set the brightness of your Tuya light. This is an 8-bit `integer` corresponding to a percentage from 0%-100% (though Tuya lights typically don't accept a brightness value below `32`). Status-Light defaults to 50% brightness, `128`.
363+
**Legacy Format Auto-Detection:** For backward compatibility, values above 100 (or in the range 32-100) are automatically detected as the legacy 0-255 format and converted to percentage. For example, `LIGHT_BRIGHTNESS=128` is auto-detected and converted to 50%. To avoid confusion, use percentage values (0-100) for new configurations.
364+
365+
**Note:** The legacy format was specific to Tuya's device scale. The new percentage format works with all light targets and is more intuitive.
366+
367+
**Backward Compatibility:** `TUYA_BRIGHTNESS` is still supported as an alias for `LIGHT_BRIGHTNESS` but is deprecated. Use `LIGHT_BRIGHTNESS` for new configurations.
336368

337369
---
338370

@@ -452,6 +484,70 @@ Since Google has [deprecated](https://developers.googleblog.com/2022/02/making-o
452484

453485
---
454486

487+
### **ICS**
488+
489+
**Note:** See [`CALENDAR_LOOKAHEAD`](#calendar_lookahead) to configure lookahead timing for Calendar sources.
490+
491+
Status-Light uses the [icalendar](https://github.com/collective/icalendar) and [recurring-ical-events](https://github.com/niccokunzmann/python-recurring-ical-events) libraries to parse ICS files. These libraries correctly handle recurring events and cross-timezone event matching (e.g., a Pacific time event will be correctly detected when running in Mountain time).
492+
493+
Status-Light's ICS source implements **RFC 5545 compliant** status detection based on the `TRANSP` (transparency) and `STATUS` properties, **plus Microsoft CDO extensions** for enhanced Office 365/Outlook compatibility:
494+
495+
**Standard RFC 5545 Properties:**
496+
497+
| Event Properties | Status-Light Status |
498+
|-----------------|---------------------|
499+
| `TRANSP=TRANSPARENT` | `free` (event doesn't block time) |
500+
| `STATUS=CANCELLED` | `free` (event was cancelled) |
501+
| `STATUS=TENTATIVE` | `tentative` (maps to BUSY-TENTATIVE in RFC terms) |
502+
| `STATUS=CONFIRMED` or unset | `busy` (default blocking event) |
503+
504+
**Microsoft CDO Extensions** (Office 365/Outlook ICS exports):
505+
506+
When present, the `X-MICROSOFT-CDO-BUSYSTATUS` property takes precedence and provides more granular status information:
507+
508+
| CDO BUSYSTATUS Property | Status-Light Status |
509+
|------------------------|---------------------|
510+
| `FREE` or `0` | `free` |
511+
| `TENTATIVE` or `1` | `tentative` |
512+
| `BUSY` or `2` | `busy` |
513+
| `OOF` or `3` | `outofoffice` (away/out of office) |
514+
| `WORKINGELSEWHERE` or `4` | `workingelsewhere` (remote work) |
515+
516+
**Status Precedence:** When multiple events exist in the lookahead window, the "busiest" status wins: `busy` > `outofoffice` > `workingelsewhere` > `tentative` > `free`.
517+
518+
#### `ICS_URL`
519+
520+
- *Required if `ics` is present in [`SOURCES`](#sources)*
521+
522+
The URL to an ICS (iCalendar) file. This can be any publicly accessible URL that returns a valid `.ics` file, such as:
523+
- A shared Google Calendar ICS link
524+
- An Office 365 published calendar
525+
- Any other iCalendar-compatible calendar export
526+
527+
Status-Light will fetch this file periodically (controlled by `ICS_CACHELIFETIME`) and check for events within the [`CALENDAR_LOOKAHEAD`](#calendar_lookahead) window.
528+
529+
**Docker Secrets:** This variable can instead be specified in a secrets file, using the `ICS_URL_FILE` variable.
530+
531+
#### `ICS_CACHESTORE`
532+
533+
- *Optional, only valid if `ics` is present in [`SOURCES`](#sources)*
534+
- Acceptable value: Any writable location on disk, e.g. `/path/to/cache/`
535+
- Default value: `~`
536+
537+
Defines a writable location on disk where the cached ICS file is stored.
538+
539+
**Note:** This path is directory only. Status-Light will persist a file named `status-light-ics-cache.ics` within the directory supplied.
540+
541+
#### `ICS_CACHELIFETIME`
542+
543+
- *Optional, only valid if `ics` is present in [`SOURCES`](#sources)*
544+
- Acceptable range: `5`-`60`
545+
- Default value: `30`
546+
547+
Set the number of minutes the cached ICS file remains valid before being re-fetched from the URL. A lower value means more frequent updates but more network requests.
548+
549+
---
550+
455551
### **Active Times**
456552

457553
If you prefer to leave Status-Light running all the time (e.g. headless in a Docker container), you may wish to disable status polling during off hours.

requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ O365
55
google-api-python-client
66
google-auth-httplib2
77
google-auth-oauthlib
8-
slack-sdk
8+
slack-sdk
9+
icalendar
10+
recurring-ical-events

status-light/sources/calendar/google.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Status-Light
2-
(c) 2020-2023 Nick Warner
2+
(c) 2020-2026 Nick Warner
33
https://github.com/portableprogrammer/Status-Light/
44
55
Google Calendar Source

0 commit comments

Comments
 (0)