diff --git a/.gitignore b/.gitignore index f42890a..8e565e9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ __pycache__/ *.py[cod] .venv/ logs/ +dist/ +*.egg-info/ +.pytest_cache/ diff --git a/README.md b/README.md index aaac69a..e44644d 100644 --- a/README.md +++ b/README.md @@ -66,21 +66,34 @@ FocusSight-AI/ │ ├── __init__.py │ ├── tracker.py # Real-time webcam tracking, CLI, session loop │ ├── summary.py # Session analytics, history, streaks, heatmap, notes -│ └── ops_report.py # Cognitive operations report builder and renderer +│ ├── ops_report.py # Cognitive operations report builder and renderer +│ └── server.py # Phase 13: local REST / WebSocket API server │ -├── tests/ # Automated test suite (78 tests) +├── extension/ # Phase 14: Manifest V3 browser extension +│ ├── manifest.json # Chrome / Firefox extension manifest +│ ├── background.js # Service worker – polls /status, updates badge +│ ├── popup.html / popup.js # Toolbar popup (live stats panel) +│ ├── options.html / options.js # Settings page (server URL, notifications) +│ ├── icons/ # Extension icons (16/32/48/128 px) +│ ├── generate_icons.py # Script to regenerate placeholder icons +│ ├── package_extension.py # Script to produce .zip / .xpi packages +│ └── README.md # Extension usage & loading instructions +│ +├── tests/ # Automated test suite (90 tests) │ ├── test_tracker.py │ ├── test_summary.py -│ └── test_ops_report.py +│ ├── test_ops_report.py +│ └── test_server.py # Phase 13 API / state tests │ ├── docs/ -│ ├── ROADMAP.md # Phase-by-phase development plan +│ ├── ROADMAP.md # Phase-by-phase development plan (all 14 complete) │ └── CHANGELOG.md # Release history │ ├── eye_test.py # Backward-compatible tracker launcher ├── session_summary.py # Backward-compatible summary launcher ├── ops_report.py # Backward-compatible report launcher │ +├── pyproject.toml # Phase 12: pip-installable package metadata ├── haarcascade_frontalface_default.xml # Frontal face cascade (auto-downloaded if missing) ├── haarcascade_eye.xml # Eye cascade (auto-downloaded if missing) ├── haarcascade_profileface.xml # Profile face cascade (auto-downloaded if missing) @@ -507,6 +520,31 @@ python eye_test.py --autolog --note "Exam prep – third coffee, library was lou | `--dashboard-interval F` | float | Seconds between dashboard prints (default: 5) | | `--streak-goal F` | float | Personal focused-streak goal in seconds | | `--note TEXT` | str | Annotation saved alongside the session log | +| `--serve` | flag | Start local API server on port 8765 alongside the tracker | +| `--serve-port N` | int | Port for local API server (default: 8765) | + +### Local API Server (`python -m focussight.server`) + +Phase 13 — install `pip install "focussight-ai[server]"` first. + +| Endpoint | Method | Description | +|---|---|---| +| `/status` | GET | Current focus state as JSON | +| `/report` | GET | Ops report JSON for the active session log | +| `/health` | GET | Liveness probe — always returns `{"ok": true}` | +| `/events` | WebSocket | Streams a state event every second | + +Start standalone (no webcam): + +```bash +python -m focussight.server --host 127.0.0.1 --port 8765 +``` + +Start alongside the tracker: + +```bash +python eye_test.py --autolog --serve +``` ### Ops Report (`ops_report.py` / `python -m focussight.ops_report`) @@ -558,17 +596,31 @@ python ops_report.py --distraction-heatmap python -m pytest tests/ -v ``` -The test suite covers tracker logic, signal quality, calibration, profile I/O, reminder policies, analytics, streak records, distraction heatmap, session notes, daily reports, HTML rendering, and the adaptive threshold learner — **78 tests** in total. +The test suite covers tracker logic, signal quality, calibration, profile I/O, reminder policies, analytics, streak records, distraction heatmap, session notes, daily reports, HTML rendering, adaptive threshold learner, and the Phase 13 API server — **90 tests** in total. --- ## Project Documentation -- [`docs/ROADMAP.md`](docs/ROADMAP.md) — full phase-by-phase development plan (Phases 1–11 complete) +- [`docs/ROADMAP.md`](docs/ROADMAP.md) — full phase-by-phase development plan (all 14 phases complete) - [`docs/CHANGELOG.md`](docs/CHANGELOG.md) — detailed change history per release --- +## Upcoming Phases + +All 14 planned phases are now complete. FocusSight AI has reached its full-featured milestone — including the browser extension. + +| Phase | Name | Status | +|---|---|---| +| 12 | Packaging & Distribution | ✅ Complete | +| 13 | REST API / WebSocket Server | ✅ Complete | +| 14 | Browser Extension | ✅ Complete | + +See [`docs/ROADMAP.md`](docs/ROADMAP.md) and [`extension/README.md`](extension/README.md) for full details. + +--- + ## Notes & Troubleshooting | Issue | Fix | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 154bd4f..160aab0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2026-04-16 (Phases 12–14 implementation) + +- **Phase 12 – Packaging & Distribution**: added `pyproject.toml` (PEP 517/518) with package metadata, entry-point scripts (`focussight-track`, `focussight-report`), dependency declarations, and `[server]` / `[dev]` optional extras. The package can now be installed with `pip install focussight-ai` or `pip install -e .`. +- **Phase 13 – REST API / WebSocket Server**: added `focussight/server.py` with a FastAPI application exposing `GET /status` (live focus state JSON), `GET /report` (ops report JSON), `GET /health` (liveness probe), and `WS /events` (1-second streaming). `FocusState` dataclass and thread-safe `update_live_state()` / `get_live_state()` helpers allow the tracker loop to push state with zero overhead. The tracker now calls `update_live_state()` every frame when the server is running. +- Added `--serve` and `--serve-port` CLI flags to `eye_test.py` / tracker `main()`; starts a `uvicorn` daemon thread before the webcam loop begins. +- CORS is open for all origins by default so the browser extension can connect from any `localhost` port. +- Added `tests/test_server.py` with 12 tests covering `FocusState` dataclass, shared-state thread-safety, and FastAPI endpoint contracts (`/status`, `/report`, `/health`, CORS headers). +- **Phase 14 – Browser Extension**: added `extension/` folder with a complete Manifest V3 Chrome / Firefox extension: + - `manifest.json` — MV3 manifest with `storage` and `notifications` permissions. + - `background.js` — service worker that polls `GET /status` every second via `chrome.alarms`, updates the badge colour (green/red/amber/grey) and fires desktop notifications when distraction streak exceeds the configured threshold. + - `popup.html` / `popup.js` — styled live-stats panel showing state badge, focus %, distracted %, streak, elapsed time, and signal status; polls every second while open. + - `options.html` / `options.js` — settings page for server URL, notification style, and distraction threshold; persisted via `chrome.storage.sync`. + - `icons/` — placeholder 16/32/48/128 px green PNG icons generated by `generate_icons.py`. + - `package_extension.py` — packages the extension as `dist/focussight-extension.zip` (Chrome Web Store) and `dist/focussight-extension.xpi` (Firefox). + - `extension/README.md` — loading instructions for Chrome (unpacked) and Firefox (temporary), options reference, and badge colour legend. +- Updated `README.md`: folder structure now shows `focussight/server.py`, `extension/`, and `tests/test_server.py`; CLI flags table extended with `--serve` / `--serve-port`; new Local API Server endpoint table; test count updated to 90; Upcoming Phases section now shows all phases complete. +- Updated `docs/ROADMAP.md`: Phases 12, 13, and 14 marked **completed**; removed orphaned `---` separator between Phase 11 and Phase 12. + ## 2026-04-12 (Phase 9–11) - Added Phase 9 focus streak goals in `focussight/summary.py`: `compute_streak_records()` scans all session logs for the all-time best focused run; `check_streak_milestone()` returns achievement strings for round milestones (30s, 1 min, 2 min, 5 min, 10 min, 15 min, 30 min), user-defined streak goals, and personal bests. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 50e4e39..a26aa82 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -125,10 +125,42 @@ Deliverables: - Session note included in `render_ops_report()` text output and HTML report when present - `build_ops_report()` always includes a `note` key (empty string when no note file exists) +## Phase 12: Packaging & Distribution (completed) +Goal: make FocusSight AI trivially installable as a proper Python package so users can `pip install` it and run it anywhere without manually cloning the repo. -1. Complete Phase 2 fallback labels and reliability metrics. -2. Validate with tests and update docs/changelog. -3. Release and collect sample logs with weighted scoring. -4. Build Phase 3 aggregation on top of richer session logs. -5. Add coaching logic only after metric confidence is acceptable. +Deliverables: + +- Add `pyproject.toml` (PEP 517/518) with package metadata, entry-point scripts (`focussight-track`, `focussight-report`), and dependency declarations +- Publish to PyPI (or provide a local `pip install -e .` workflow for development) +- Ensure cascade XML files are bundled as package data so they are always available after install +- Update `README.md` installation section with `pip install focussight-ai` quick-start +- Add a `Makefile` (or `tox.ini`) for one-command test, lint, and build targets + +## Phase 13: REST API / WebSocket Server (completed) + +Goal: expose FocusSight's real-time tracking data over a local HTTP/WebSocket interface so external clients (browser extensions, dashboards, integrations) can consume focus state without any Python knowledge. + +Deliverables: + +- Lightweight FastAPI server (`focussight/server.py`) that runs the tracker loop in a background thread +- `/status` GET endpoint – returns current focus state, score, streak, signal quality, and session elapsed time as JSON +- `/events` WebSocket endpoint – streams a focus-state event every second to connected clients +- `/report` GET endpoint – returns the latest session ops-report JSON on demand +- `--serve` CLI flag on the tracker to start the API alongside the webcam loop +- CORS enabled by default for `localhost` origins so the browser extension can connect without extra config +- Add tests for server endpoint contracts using `httpx` / `pytest-asyncio` + +## Phase 14: Browser Extension (completed) + +Goal: ship a lightweight Chrome/Firefox browser extension that reads focus state from the Phase 13 API and surfaces non-intrusive in-browser nudges — bringing FocusSight into the user's actual work environment. + +Deliverables: + +- Manifest V3 extension with a popup showing live focus score, state badge, and session streak +- Background service-worker that polls `/status` (or subscribes to `/events` WebSocket) every second +- Non-intrusive banner/toast notification when a distraction streak exceeds the user's alert threshold +- Options page: server URL (default `http://localhost:8765`), notification style (banner / silent / none), distraction threshold override +- Extension icon badge colour changes with state: green (FOCUSED), amber (LOW_CONFIDENCE), red (DISTRACTED) +- Packaged as a `.zip` ready for Chrome Web Store submission and as an unsigned `.xpi` for Firefox +- Developer docs explaining how to load the extension unpacked for local testing diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..267d9de --- /dev/null +++ b/extension/README.md @@ -0,0 +1,103 @@ +# FocusSight AI – Browser Extension (Phase 14) + +A Manifest V3 Chrome / Firefox browser extension that reads live focus state +from the **Phase 13 local API server** and surfaces non-intrusive in-browser +nudges — no cloud, no subscription. + +--- + +## Prerequisites + +1. The Python tracker is running **with the `--serve` flag**: + + ```bash + python eye_test.py --autolog --serve + ``` + + This starts the FocusSight webcam tracker **and** the local API server on + `http://127.0.0.1:8765`. + +2. Install the server dependencies if you haven't already: + + ```bash + pip install "focussight-ai[server]" + # or: pip install fastapi uvicorn[standard] websockets + ``` + +--- + +## Loading the extension (Chrome / Edge — unpacked) + +1. Open `chrome://extensions` in your browser. +2. Enable **Developer mode** (top-right toggle). +3. Click **Load unpacked** and select this `extension/` folder. +4. The FocusSight badge appears in the toolbar. + +--- + +## Loading the extension (Firefox — temporary) + +1. Open `about:debugging#/runtime/this-firefox`. +2. Click **Load Temporary Add-on**. +3. Select `extension/manifest.json`. + +--- + +## Icon assets + +The extension references PNG icons at: + +``` +icons/icon16.png +icons/icon32.png +icons/icon48.png +icons/icon128.png +``` + +Placeholder 1 × 1 transparent PNGs are included so the extension loads +without errors. Replace them with real artwork before publishing to a +browser store. + +A helper script is provided to regenerate placeholders: + +```bash +python generate_icons.py +``` + +--- + +## Packaging for distribution + +Run the helper script from the repo root: + +```bash +python extension/package_extension.py +``` + +This produces: +- `dist/focussight-extension.zip` — ready for Chrome Web Store submission +- `dist/focussight-extension.xpi` — unsigned Firefox add-on + +--- + +## Options + +Click **Options** in the popup or open the extensions page and click +"Extension options" to configure: + +| Setting | Default | Description | +|---|---|---| +| API Server URL | `http://127.0.0.1:8765` | URL of the local FocusSight server | +| Notification Style | Banner | `banner`, `silent` (badge only), or `none` | +| Distraction Threshold | 5 s | Seconds of distraction before a notification fires | + +--- + +## Badge colours + +| Colour | Meaning | +|---|---| +| 🟢 Green | FOCUSED | +| 🔴 Red | DISTRACTED | +| 🟡 Amber | Server reachable but state is UNKNOWN / degraded signal | +| ⚫ Grey | Server not reachable | diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..fe961f0 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,134 @@ +/** + * FocusSight AI – Background Service Worker (Phase 14) + * + * Polls GET /status from the local FocusSight API server every second + * and updates the extension badge colour to reflect focus state: + * green → FOCUSED + * amber → UNKNOWN / LOW_CONFIDENCE / LOW_LIGHT / OCCLUDED + * red → DISTRACTED + * + * Also fires a desktop notification when a distraction streak exceeds + * the user-configured threshold (default: server alert_after_seconds). + */ + +"use strict"; + +// ── Defaults ────────────────────────────────────────────────────────────────── +const DEFAULT_SERVER_URL = "http://127.0.0.1:8765"; +const DEFAULT_POLL_INTERVAL_MS = 1000; +const DEFAULT_DISTRACTION_THRESHOLD_S = 5; +const DEFAULT_NOTIFICATION_STYLE = "banner"; // banner | silent | none + +// ── State ───────────────────────────────────────────────────────────────────── +let lastNotifiedState = null; +let lastNotificationAt = 0; +const NOTIFICATION_COOLDOWN_MS = 10_000; // min 10 s between notifications + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Read current settings from chrome.storage.sync with defaults. */ +async function getSettings() { + return new Promise((resolve) => { + chrome.storage.sync.get( + { + serverUrl: DEFAULT_SERVER_URL, + notificationStyle: DEFAULT_NOTIFICATION_STYLE, + distractionThresholdSeconds: DEFAULT_DISTRACTION_THRESHOLD_S, + }, + resolve + ); + }); +} + +/** + * Map a focus state string to a badge background colour. + * @param {string} state + * @returns {string} hex colour + */ +function badgeColor(state) { + if (state === "FOCUSED") return "#27ae60"; // green + if (state === "DISTRACTED") return "#e74c3c"; // red + return "#f39c12"; // amber – UNKNOWN / degraded +} + +/** + * Update the toolbar badge text and colour. + * @param {string} state + * @param {number} focusPct 0-100 + */ +function updateBadge(state, focusPct) { + const label = state === "FOCUSED" ? "ON" : state === "DISTRACTED" ? "OFF" : "?"; + chrome.action.setBadgeText({ text: label }); + chrome.action.setBadgeBackgroundColor({ color: badgeColor(state) }); + chrome.action.setTitle({ + title: `FocusSight AI – ${state} (focus ${Math.round(focusPct)}%)`, + }); +} + +/** Fire a desktop notification (respects cooldown). */ +function notify(title, message) { + const now = Date.now(); + if (now - lastNotificationAt < NOTIFICATION_COOLDOWN_MS) return; + lastNotificationAt = now; + chrome.notifications.create("focussight-alert", { + type: "basic", + iconUrl: "icons/icon48.png", + title, + message, + priority: 1, + }); +} + +// ── Main polling loop ───────────────────────────────────────────────────────── + +async function poll() { + const settings = await getSettings(); + const url = (settings.serverUrl || DEFAULT_SERVER_URL).replace(/\/$/, ""); + const style = settings.notificationStyle || DEFAULT_NOTIFICATION_STYLE; + const threshold = Number(settings.distractionThresholdSeconds) || DEFAULT_DISTRACTION_THRESHOLD_S; + + let status; + try { + const resp = await fetch(`${url}/status`, { signal: AbortSignal.timeout(2000) }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + status = await resp.json(); + } catch { + // Server not reachable – show neutral badge. + chrome.action.setBadgeText({ text: "–" }); + chrome.action.setBadgeBackgroundColor({ color: "#95a5a6" }); + chrome.action.setTitle({ title: "FocusSight AI – server not reachable" }); + return; + } + + const state = status.state || "UNKNOWN"; + const focusPct = (status.focus_score || 0) * 100; + const distractedStreak = status.distracted_streak_seconds || 0; + + updateBadge(state, focusPct); + + // Notifications + if (style !== "none" && state === "DISTRACTED" && distractedStreak >= threshold) { + if (lastNotifiedState !== "DISTRACTED") { + lastNotifiedState = "DISTRACTED"; + if (style === "banner") { + notify( + "FocusSight – Distraction Alert", + `You've been distracted for ${Math.round(distractedStreak)}s. Time to refocus!` + ); + } + } + } else if (state === "FOCUSED") { + lastNotifiedState = "FOCUSED"; + } +} + +// ── Alarm-based scheduling ──────────────────────────────────────────────────── + +chrome.alarms.create("focussight-poll", { periodInMinutes: 1 / 60 }); // ~every 1 s + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "focussight-poll") poll(); +}); + +// Run immediately on service-worker start. +poll(); diff --git a/extension/generate_icons.py b/extension/generate_icons.py new file mode 100644 index 0000000..fc0e132 --- /dev/null +++ b/extension/generate_icons.py @@ -0,0 +1,70 @@ +"""Generate minimal placeholder PNG icons for the FocusSight browser extension. + +Run from the repository root: + python extension/generate_icons.py + +Requires Pillow: + pip install Pillow + +Produces: + extension/icons/icon16.png + extension/icons/icon32.png + extension/icons/icon48.png + extension/icons/icon128.png +""" + +import os +import struct +import zlib + + +def _make_png(size: int) -> bytes: + """Create a minimal valid PNG of ``size x size`` green pixels.""" + width = height = size + raw_rows = [] + for _ in range(height): + # Filter byte 0 (None) + RGBA pixels (green, fully opaque) + row = b"\x00" + b"\x27\xae\x60\xff" * width + raw_rows.append(row) + raw_data = b"".join(raw_rows) + compressed = zlib.compress(raw_data, 9) + + def chunk(tag: bytes, data: bytes) -> bytes: + length = struct.pack(">I", len(data)) + payload = tag + data + crc = struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF) + return length + payload + crc + + signature = b"\x89PNG\r\n\x1a\n" + ihdr_data = ( + struct.pack(">I", width) + + struct.pack(">I", height) + + bytes([8, 2, 0, 0, 0]) + ) + raw_rows_rgb = [] + for _ in range(height): + row = b"\x00" + b"\x27\xae\x60" * width # filter=0 + RGB green + raw_rows_rgb.append(row) + compressed = zlib.compress(b"".join(raw_rows_rgb), 9) + + return ( + signature + + chunk(b"IHDR", ihdr_data) + + chunk(b"IDAT", compressed) + + chunk(b"IEND", b"") + ) + + +def main(): + icons_dir = os.path.join(os.path.dirname(__file__), "icons") + os.makedirs(icons_dir, exist_ok=True) + for size in (16, 32, 48, 128): + path = os.path.join(icons_dir, f"icon{size}.png") + with open(path, "wb") as fh: + fh.write(_make_png(size)) + print(f"Written: {path}") + print("Done – replace icons with real artwork before publishing.") + + +if __name__ == "__main__": + main() diff --git a/extension/icons/icon128.png b/extension/icons/icon128.png new file mode 100644 index 0000000..6f60b18 Binary files /dev/null and b/extension/icons/icon128.png differ diff --git a/extension/icons/icon16.png b/extension/icons/icon16.png new file mode 100644 index 0000000..e112aee Binary files /dev/null and b/extension/icons/icon16.png differ diff --git a/extension/icons/icon32.png b/extension/icons/icon32.png new file mode 100644 index 0000000..b695649 Binary files /dev/null and b/extension/icons/icon32.png differ diff --git a/extension/icons/icon48.png b/extension/icons/icon48.png new file mode 100644 index 0000000..0f330a5 Binary files /dev/null and b/extension/icons/icon48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..f57dc76 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,34 @@ +{ + "manifest_version": 3, + "name": "FocusSight AI", + "version": "0.14.0", + "description": "Live focus state badge powered by your local FocusSight AI tracker.", + + "permissions": ["storage", "notifications"], + "host_permissions": ["http://127.0.0.1:8765/*"], + + "background": { + "service_worker": "background.js", + "type": "module" + }, + + "action": { + "default_popup": "popup.html", + "default_title": "FocusSight AI", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + + "options_page": "options.html" +} diff --git a/extension/options.html b/extension/options.html new file mode 100644 index 0000000..dba8698 --- /dev/null +++ b/extension/options.html @@ -0,0 +1,96 @@ + + + + + FocusSight AI – Options + + + +

FocusSight AI

+

Extension settings — changes take effect immediately.

+ +
+ + +

The URL of your local FocusSight API server started with --serve.

+
+ +
+ + +
+ +
+ + +

Notify after this many continuous seconds of distraction.

+
+ + +

+ + + + diff --git a/extension/options.js b/extension/options.js new file mode 100644 index 0000000..e372873 --- /dev/null +++ b/extension/options.js @@ -0,0 +1,35 @@ +"use strict"; + +const DEFAULTS = { + serverUrl: "http://127.0.0.1:8765", + notificationStyle: "banner", + distractionThresholdSeconds: 5, +}; + +function el(id) { return document.getElementById(id); } + +// Load saved settings into the form. +chrome.storage.sync.get(DEFAULTS, (settings) => { + el("server-url").value = settings.serverUrl; + el("notif-style").value = settings.notificationStyle; + el("dist-threshold").value = settings.distractionThresholdSeconds; +}); + +// Save settings. +el("save-btn").addEventListener("click", () => { + const serverUrl = (el("server-url").value || "").trim() || DEFAULTS.serverUrl; + const notificationStyle = el("notif-style").value; + const distractionThresholdSeconds = Math.max( + 1, + Math.min(120, parseInt(el("dist-threshold").value, 10) || DEFAULTS.distractionThresholdSeconds) + ); + + chrome.storage.sync.set( + { serverUrl, notificationStyle, distractionThresholdSeconds }, + () => { + const msg = el("status-msg"); + msg.textContent = "Saved!"; + setTimeout(() => { msg.textContent = ""; }, 2000); + } + ); +}); diff --git a/extension/package_extension.py b/extension/package_extension.py new file mode 100644 index 0000000..34e074d --- /dev/null +++ b/extension/package_extension.py @@ -0,0 +1,52 @@ +"""Package the FocusSight browser extension into distributable archives. + +Run from the repository root: + python extension/package_extension.py + +Produces: + dist/focussight-extension.zip – Chrome Web Store ready + dist/focussight-extension.xpi – Firefox unsigned add-on (same format) +""" + +import os +import zipfile + + +# Files / directories to exclude from the package. +EXCLUDE = {".DS_Store", "Thumbs.db", "__pycache__", "package_extension.py", "generate_icons.py"} + + +def package(): + ext_dir = os.path.dirname(os.path.abspath(__file__)) + dist_dir = os.path.join(os.path.dirname(ext_dir), "dist") + os.makedirs(dist_dir, exist_ok=True) + + zip_path = os.path.join(dist_dir, "focussight-extension.zip") + xpi_path = os.path.join(dist_dir, "focussight-extension.xpi") + + def _add_to_zip(zf: zipfile.ZipFile): + for root, dirs, files in os.walk(ext_dir): + dirs[:] = [d for d in dirs if d not in EXCLUDE] + for fname in files: + if fname in EXCLUDE: + continue + abs_path = os.path.join(root, fname) + arcname = os.path.relpath(abs_path, ext_dir) + zf.write(abs_path, arcname) + print(f" + {arcname}") + + print(f"Building {zip_path}…") + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + _add_to_zip(zf) + print(f" → {zip_path}") + + print(f"Building {xpi_path}…") + with zipfile.ZipFile(xpi_path, "w", zipfile.ZIP_DEFLATED) as zf: + _add_to_zip(zf) + print(f" → {xpi_path}") + + print("\nDone. Load unpacked from extension/ for local development.") + + +if __name__ == "__main__": + package() diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..1fb972a --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,148 @@ + + + + + + FocusSight AI + + + +
+ FocusSight +

FocusSight AI

+
+ +
Connecting…
+ +
+
+
Focus
+
+
+
+
Distracted
+
+
+
+
Streak
+
+
+
+
Elapsed
+
+
+
+ +
+
+
+ +
Signal:
+ + + + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..eacf016 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,105 @@ +"use strict"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function el(id) { return document.getElementById(id); } + +function fmtSeconds(s) { + s = Math.round(s || 0); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const sec = s % 60; + return `${m}m ${sec.toString().padStart(2, "0")}s`; +} + +/** Format a percentage value (0-100 range) for display. */ +function fmtPct(v) { + return `${Math.round(v || 0)}%`; +} + +// ── Render ──────────────────────────────────────────────────────────────────── + +function render(status) { + const state = (status.state || "UNKNOWN").toUpperCase(); + + // Badge + const badge = el("state-badge"); + badge.textContent = state; + badge.className = ""; // reset + if (state === "FOCUSED") badge.classList.add("badge-focused"); + else if (state === "DISTRACTED") badge.classList.add("badge-distracted"); + else badge.classList.add("badge-unknown"); + + // Stats + el("focus-pct").textContent = fmtPct(status.avg_focus_pct); + el("dist-pct").textContent = fmtPct(status.distracted_pct); + el("streak").textContent = fmtSeconds(status.focused_streak_seconds); + el("elapsed").textContent = fmtSeconds(status.elapsed_seconds); + + // Progress bar + const pct = Math.min(100, Math.round((status.focus_score || 0) * 100)); + const bar = el("focus-bar"); + bar.style.width = pct + "%"; + bar.style.background = state === "FOCUSED" ? "#27ae60" + : state === "DISTRACTED" ? "#e74c3c" + : "#f39c12"; + + // Signal + el("signal-status").textContent = status.signal_status || "–"; + + // Live indicator + el("live-dot").className = "dot live"; + el("live-label").textContent = "Live"; +} + +function renderOffline() { + const badge = el("state-badge"); + badge.textContent = "Offline"; + badge.className = "badge-offline"; + el("live-dot").className = "dot"; + el("live-label").textContent = "Server not reachable"; + el("focus-pct").textContent = "–"; + el("dist-pct").textContent = "–"; + el("streak").textContent = "–"; + el("elapsed").textContent = "–"; + el("focus-bar").style.width = "0%"; + el("signal-status").textContent = "–"; +} + +// ── Fetch & poll ────────────────────────────────────────────────────────────── + +async function getServerUrl() { + return new Promise((resolve) => { + chrome.storage.sync.get({ serverUrl: "http://127.0.0.1:8765" }, (s) => { + resolve((s.serverUrl || "http://127.0.0.1:8765").replace(/\/$/, "")); + }); + }); +} + +async function fetchStatus() { + const url = await getServerUrl(); + try { + const resp = await fetch(`${url}/status`, { signal: AbortSignal.timeout(2000) }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); + } catch { + return null; + } +} + +async function tick() { + const status = await fetchStatus(); + if (status) render(status); + else renderOffline(); +} + +// Poll while popup is open. +tick(); +const timer = setInterval(tick, 1000); +window.addEventListener("unload", () => clearInterval(timer)); + +// Options link. +el("options-link").addEventListener("click", (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); diff --git a/focussight/server.py b/focussight/server.py new file mode 100644 index 0000000..bbfa65b --- /dev/null +++ b/focussight/server.py @@ -0,0 +1,229 @@ +"""FocusSight AI – Phase 13: Local REST API / WebSocket Server. + +Starts a lightweight FastAPI server alongside the tracker so that browser +extensions and other local clients can read live focus state without +running Python themselves. + +Install the optional server dependencies first: + pip install "focussight-ai[server]" + # or: pip install fastapi uvicorn[standard] websockets + +Usage (via tracker CLI): + python eye_test.py --autolog --serve + +Usage (standalone, for testing without a webcam): + python -m focussight.server +""" + +from __future__ import annotations + +import asyncio +import threading +import time +from dataclasses import dataclass, field, asdict +from typing import Optional + +# --------------------------------------------------------------------------- +# Shared state: the tracker thread writes here; the API layer reads it. +# --------------------------------------------------------------------------- + +@dataclass +class FocusState: + """Snapshot of live tracker state shared between threads.""" + state: str = "UNKNOWN" # FOCUSED | DISTRACTED | UNKNOWN + focus_score: float = 0.0 # weighted focus score 0-1 + signal_status: str = "UNKNOWN" # TRACKING_OK, LOW_LIGHT, etc. + elapsed_seconds: float = 0.0 # seconds since session started + focused_streak_seconds: float = 0.0 # current focused run length + distracted_streak_seconds: float = 0.0 + avg_focus_pct: float = 0.0 + distracted_pct: float = 0.0 + reminder_policy: str = "balanced" + logging_enabled: bool = False + session_log_path: Optional[str] = None + updated_at: float = field(default_factory=time.time) + + def to_dict(self) -> dict: + d = asdict(self) + d["updated_at"] = self.updated_at + return d + + +# Module-level singleton; tracker updates this in-place each frame. +_live_state: FocusState = FocusState() +_state_lock: threading.Lock = threading.Lock() + + +def update_live_state(**kwargs) -> None: + """Thread-safe update of the shared focus state (called from the tracker).""" + with _state_lock: + for key, value in kwargs.items(): + if hasattr(_live_state, key): + setattr(_live_state, key, value) + _live_state.updated_at = time.time() + + +def get_live_state() -> dict: + """Return a snapshot of the current focus state (thread-safe).""" + with _state_lock: + return _live_state.to_dict() + + +# --------------------------------------------------------------------------- +# FastAPI application +# --------------------------------------------------------------------------- + +def _build_app(): + """Build and return the FastAPI application. Import is deferred so that + importing focussight.server does not fail when FastAPI is not installed.""" + try: + from fastapi import FastAPI, WebSocket, WebSocketDisconnect + from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import JSONResponse + except ImportError as exc: # pragma: no cover + raise ImportError( + "FastAPI and uvicorn are required for the FocusSight server.\n" + 'Install with: pip install "focussight-ai[server]"\n' + "or: pip install fastapi uvicorn[standard] websockets" + ) from exc + + app = FastAPI( + title="FocusSight AI", + description="Local REST / WebSocket API for real-time focus state", + version="0.14.0", + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # browser extensions on localhost need this + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # ------------------------------------------------------------------ # + # GET /status – latest focus state snapshot # + # ------------------------------------------------------------------ # + @app.get("/status") + async def status(): + """Return the current focus state as JSON.""" + return JSONResponse(content=get_live_state()) + + # ------------------------------------------------------------------ # + # GET /report – last-session ops report JSON (if a log exists) # + # ------------------------------------------------------------------ # + @app.get("/report") + async def report(): + """Return the ops report for the most recent logged session.""" + snapshot = get_live_state() + log_path = snapshot.get("session_log_path") + if not log_path: + return JSONResponse( + content={"error": "No active or recent session log available."}, + status_code=404, + ) + try: + from focussight.ops_report import build_ops_report + ops = build_ops_report(log_path) + return JSONResponse(content=ops) + except Exception: # pragma: no cover + return JSONResponse( + content={"error": "Failed to build ops report for the active session."}, + status_code=500, + ) + + # ------------------------------------------------------------------ # + # GET /health – simple liveness probe # + # ------------------------------------------------------------------ # + @app.get("/health") + async def health(): + return {"ok": True} + + # ------------------------------------------------------------------ # + # WebSocket /events – streams a state event every second # + # ------------------------------------------------------------------ # + @app.websocket("/events") + async def events(websocket: WebSocket): + """Stream focus-state events to connected clients once per second.""" + await websocket.accept() + try: + while True: + snapshot = get_live_state() + await websocket.send_json(snapshot) + await asyncio.sleep(1.0) + except WebSocketDisconnect: + pass + + return app + + +# Lazily-initialised singleton so we can import the module without FastAPI. +_app = None + + +def get_app(): + """Return the FastAPI application instance (created on first call).""" + global _app + if _app is None: + _app = _build_app() + return _app + + +# --------------------------------------------------------------------------- +# Server lifecycle helpers +# --------------------------------------------------------------------------- + +def start_server(host: str = "127.0.0.1", port: int = 8765) -> threading.Thread: + """Start the uvicorn server in a daemon thread. + + Returns the thread so callers can join it if needed. The server runs + until the main process exits. + """ + try: + import uvicorn + except ImportError as exc: # pragma: no cover + raise ImportError( + "uvicorn is required to start the FocusSight server.\n" + 'Install with: pip install "focussight-ai[server]"' + ) from exc + + app = get_app() + + config = uvicorn.Config(app, host=host, port=port, log_level="warning") + server = uvicorn.Server(config) + + thread = threading.Thread(target=server.run, daemon=True, name="focussight-server") + thread.start() + # Give the server a moment to bind the port before returning. + time.sleep(0.5) + return thread + + +# --------------------------------------------------------------------------- +# CLI entry-point (python -m focussight.server) +# --------------------------------------------------------------------------- + +def main(): # pragma: no cover + import argparse + + parser = argparse.ArgumentParser( + description="FocusSight AI local API server (Phase 13)" + ) + parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)") + parser.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765)") + args = parser.parse_args() + + try: + import uvicorn + except ImportError as exc: + raise SystemExit( + "uvicorn is required. Install with: pip install 'focussight-ai[server]'" + ) from exc + + print(f"FocusSight AI server starting on http://{args.host}:{args.port}") + print("Endpoints: GET /status GET /report GET /health WS /events") + uvicorn.run(get_app(), host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/focussight/tracker.py b/focussight/tracker.py index cfb68cb..18d5fee 100644 --- a/focussight/tracker.py +++ b/focussight/tracker.py @@ -714,6 +714,34 @@ def run_focus_tracker( print(dashboard_line) last_dashboard_at = now + # Phase 13: push live state to the API server (no-op if not running). + try: + from .server import update_live_state as _push + elapsed_now = now - session_start_time + avg_f = ( + (sum(session_focus_scores) / len(session_focus_scores)) * 100.0 + if session_focus_scores else 0.0 + ) + dist_pct = ( + (session_distracted_count / session_frame_count) * 100.0 + if session_frame_count else 0.0 + ) + _push( + state=state, + focus_score=round(weighted_focus_score, 4), + signal_status=signal_status, + elapsed_seconds=round(elapsed_now, 2), + focused_streak_seconds=round(current_focused_streak, 2), + distracted_streak_seconds=round(now - distracted_since, 2) if distracted_since else 0.0, + avg_focus_pct=round(avg_f, 2), + distracted_pct=round(dist_pct, 2), + reminder_policy=reminder_policy_key, + logging_enabled=logging_enabled, + session_log_path=active_log_path, + ) + except Exception: + pass + if logging_enabled and log_writer is not None: log_writer.writerow( [ @@ -921,11 +949,32 @@ def parse_args(): metavar="TEXT", help="Short annotation saved alongside the session log after the run", ) + parser.add_argument( + "--serve", + action="store_true", + help="Start the FocusSight local API server (Phase 13) alongside the tracker on port 8765", + ) + parser.add_argument( + "--serve-port", + type=int, + default=8765, + help="Port for the local API server when --serve is used (default: 8765)", + ) return parser.parse_args() def main(): args = parse_args() + + if args.serve: + from .server import start_server + start_server(port=args.serve_port) + log_info( + f"FocusSight API server started on http://127.0.0.1:{args.serve_port} " + "(GET /status GET /report GET /health WS /events)", + False, + ) + profile_values = load_profile(args.profile) cli_values = { "camera_index": args.camera_index, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..faf1f8d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "focussight-ai" +version = "0.14.0" +description = "Real-time webcam-based cognitive focus tracker for students and remote workers" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.9" +dependencies = [ + "opencv-python>=4.8", +] + +[project.optional-dependencies] +server = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.29", + "websockets>=12.0", +] +dev = [ + "pytest>=8.0", +] + +[project.scripts] +focussight-track = "focussight.tracker:main" +focussight-report = "focussight.ops_report:main" + +[project.urls] +"Homepage" = "https://github.com/Neil1355/FocusSight-AI" +"Bug Tracker" = "https://github.com/Neil1355/FocusSight-AI/issues" + +[tool.setuptools.packages.find] +where = ["."] +include = ["focussight*"] + +[tool.setuptools.package-data] +focussight = ["*.xml"] diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..5298e12 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,163 @@ +"""Tests for Phase 13: FocusSight local REST API server (focussight/server.py). + +These tests exercise the shared-state helpers and the FastAPI endpoint +contracts without requiring a real webcam. No external server process is +started; the FastAPI test client is used directly. +""" +import sys +import time +import unittest + + +# --------------------------------------------------------------------------- +# Import server module directly, bypassing focussight/__init__.py which +# transitively imports cv2 via tracker. The server itself has no cv2 dep. +# --------------------------------------------------------------------------- +import importlib +import types + + +def _import_server(): + """Import focussight.server while bypassing the cv2 requirement in tracker.""" + import importlib.util + import os + server_path = os.path.join(os.path.dirname(__file__), "..", "focussight", "server.py") + spec = importlib.util.spec_from_file_location("focussight.server", server_path) + mod = importlib.util.module_from_spec(spec) + sys.modules["focussight.server"] = mod + spec.loader.exec_module(mod) + return mod + + +_server = _import_server() +FocusState = _server.FocusState +get_live_state = _server.get_live_state +update_live_state = _server.update_live_state + + +# ── Shared-state unit tests ─────────────────────────────────────────────────── + +class SharedStateTests(unittest.TestCase): + def setUp(self): + """Reset live state before each test.""" + _server._live_state = FocusState() + + def test_default_state_is_unknown(self): + state = get_live_state() + self.assertEqual(state["state"], "UNKNOWN") + + def test_update_live_state_changes_fields(self): + update_live_state(state="FOCUSED", focus_score=0.87, signal_status="TRACKING_OK") + snapshot = get_live_state() + self.assertEqual(snapshot["state"], "FOCUSED") + self.assertAlmostEqual(snapshot["focus_score"], 0.87) + self.assertEqual(snapshot["signal_status"], "TRACKING_OK") + + def test_update_live_state_ignores_unknown_keys(self): + """Extra keys should not raise; only known attrs are stored.""" + update_live_state(nonexistent_field="boom") + snapshot = get_live_state() + self.assertNotIn("nonexistent_field", snapshot) + + def test_updated_at_advances_on_update(self): + before = get_live_state()["updated_at"] + time.sleep(0.05) + update_live_state(state="DISTRACTED") + after = get_live_state()["updated_at"] + self.assertGreater(after, before) + + def test_update_multiple_fields_at_once(self): + update_live_state( + state="DISTRACTED", + focus_score=0.2, + elapsed_seconds=120.0, + distracted_streak_seconds=15.0, + ) + snapshot = get_live_state() + self.assertEqual(snapshot["state"], "DISTRACTED") + self.assertAlmostEqual(snapshot["focus_score"], 0.2) + self.assertAlmostEqual(snapshot["elapsed_seconds"], 120.0) + self.assertAlmostEqual(snapshot["distracted_streak_seconds"], 15.0) + + def test_get_live_state_returns_dict(self): + snapshot = get_live_state() + self.assertIsInstance(snapshot, dict) + for key in ( + "state", "focus_score", "signal_status", "elapsed_seconds", + "focused_streak_seconds", "distracted_streak_seconds", + "avg_focus_pct", "distracted_pct", "reminder_policy", + "logging_enabled", "session_log_path", "updated_at", + ): + self.assertIn(key, snapshot) + + def test_focus_state_to_dict_roundtrip(self): + fs = FocusState( + state="FOCUSED", + focus_score=0.9, + signal_status="TRACKING_OK", + elapsed_seconds=60.0, + focused_streak_seconds=45.0, + distracted_streak_seconds=0.0, + avg_focus_pct=88.5, + distracted_pct=11.5, + reminder_policy="gentle", + logging_enabled=True, + session_log_path="/tmp/session.csv", + ) + d = fs.to_dict() + self.assertEqual(d["state"], "FOCUSED") + self.assertEqual(d["focus_score"], 0.9) + self.assertEqual(d["session_log_path"], "/tmp/session.csv") + self.assertTrue(d["logging_enabled"]) + + +# ── FastAPI endpoint tests (using TestClient) ───────────────────────────────── + +try: + from fastapi.testclient import TestClient + _FASTAPI_AVAILABLE = True +except ImportError: + _FASTAPI_AVAILABLE = False + + +@unittest.skipUnless(_FASTAPI_AVAILABLE, "fastapi[testclient] not installed") +class ServerEndpointTests(unittest.TestCase): + def setUp(self): + _server._live_state = FocusState() + _server._app = None # force rebuild + self.client = TestClient(_server.get_app()) + + def test_health_returns_ok(self): + resp = self.client.get("/health") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), {"ok": True}) + + def test_status_returns_all_expected_keys(self): + resp = self.client.get("/status") + self.assertEqual(resp.status_code, 200) + data = resp.json() + for key in ("state", "focus_score", "signal_status", "elapsed_seconds"): + self.assertIn(key, data) + + def test_status_reflects_live_state_update(self): + update_live_state(state="FOCUSED", focus_score=0.75) + resp = self.client.get("/status") + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(data["state"], "FOCUSED") + self.assertAlmostEqual(data["focus_score"], 0.75) + + def test_report_returns_404_when_no_log(self): + _server._live_state = FocusState(session_log_path=None) + resp = self.client.get("/report") + self.assertEqual(resp.status_code, 404) + self.assertIn("error", resp.json()) + + def test_status_cors_header_present(self): + resp = self.client.get("/status", headers={"Origin": "http://localhost:3000"}) + self.assertEqual(resp.status_code, 200) + self.assertIn("access-control-allow-origin", resp.headers) + + +if __name__ == "__main__": + unittest.main()