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 AI
+
+
+ Connecting…
+
+
+
+
+
+ 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()