Compare commits

...

44 Commits

Author SHA1 Message Date
Gitea CI fe2162baa7 chore: update dependencies 2026-05-24
CI / black (pull_request) Successful in 1m33s
CI / ruff (pull_request) Successful in 1m31s
CI / mypy (pull_request) Successful in 1m43s
CI / pytest (pull_request) Failing after 1m45s
2026-05-24 22:03:10 +00:00
letteka f19ccf349b Adding recording (#29)
CI / mypy (push) Successful in 1m43s
CI / black (push) Successful in 1m34s
CI / ruff (push) Successful in 1m32s
CI / pytest (push) Failing after 1m44s
Dependency update / dependency-update (push) Successful in 1m58s
Reviewed-on: #29
Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com>
Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
2026-05-24 14:54:38 -07:00
letteka 0f8572b26e chore: dependency updates 2026-05-17 (#28)
CI / black (push) Successful in 1m30s
CI / ruff (push) Successful in 1m26s
CI / mypy (push) Successful in 1m43s
CI / pytest (push) Successful in 1m35s
Dependency update / dependency-update (push) Successful in 1m47s
## Dependency update summary

Generated: 2026-05-17 00:56 UTC

### Vulnerability scan

No vulnerabilities found.

### Outdated packages

The following packages were updated:

Was: version = "8.3.3"
Updated: version = "8.4.0"
---------

Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #28
2026-05-17 13:18:00 -07:00
letteka 8b7610671c chore: dependency updates 2026-05-17 (#27)
CI / black (push) Successful in 1m32s
CI / ruff (push) Successful in 1m29s
CI / mypy (push) Successful in 1m42s
CI / pytest (push) Successful in 1m37s
Dependency update / dependency-update (push) Successful in 1m52s
## Dependency update summary

Generated: 2026-05-17 00:21 UTC

### Vulnerability scan

No vulnerabilities found.

### Outdated packages

The following packages were updated:

Was: markers = {pi = "python_version == \"3.12\""}
Updated: markers = {pi = "python_version < \"3.13\""}
---------

Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #27
2026-05-16 17:49:02 -07:00
letteka 5f020258e8 Merge pull request 'update black' (#26) from update_black into main
CI / black (push) Successful in 1m30s
CI / ruff (push) Successful in 1m28s
CI / mypy (push) Successful in 1m42s
CI / pytest (push) Successful in 1m36s
Dependency update / dependency-update (push) Successful in 1m49s
Reviewed-on: #26
2026-05-16 17:11:20 -07:00
letteka af38580a71 update black
CI / black (pull_request) Successful in 1m29s
CI / ruff (pull_request) Successful in 1m31s
CI / mypy (pull_request) Successful in 1m42s
CI / pytest (pull_request) Successful in 1m38s
2026-05-16 17:06:49 -07:00
letteka cdaaaad6af new readme (#25)
CI / black (push) Successful in 1m30s
CI / ruff (push) Successful in 1m31s
CI / mypy (push) Successful in 1m43s
CI / pytest (push) Successful in 1m37s
Dependency update / dependency-update (push) Failing after 1m50s
Reviewed-on: #25
Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com>
Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
2026-05-16 16:45:18 -07:00
letteka bc935b41de chore: dependency updates 2026-05-16 (#24)
CI / black (push) Successful in 1m33s
CI / ruff (push) Successful in 1m30s
CI / mypy (push) Successful in 1m44s
CI / pytest (push) Successful in 1m37s
Dependency update / dependency-update (push) Failing after 1m49s
## Dependency update summary

Generated: 2026-05-16 16:41 UTC

### Vulnerability scan

Vulnerabilities found:

Name | Version | ID | Fix Versions
--- | --- | --- | ---
pytest | 8.4.2 | CVE-2025-71176 | 9.0.3

Name | Skip Reason
--- | ---
birdcam2 | Dependency not found on PyPI and could not be audited: birdcam2 (0.1.0)

### Outdated packages

The following packages were updated:

Was: markers = {pi = "python_version == \"3.12\""}
Updated: markers = {pi = "python_version < \"3.13\""}
---------

Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #24
Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com>
Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
2026-05-16 16:21:26 -07:00
letteka 02d3a9c33f Fixing ruff and mypy issues (#23)
CI / black (push) Successful in 1m31s
CI / ruff (push) Successful in 1m28s
CI / mypy (push) Successful in 1m40s
CI / pytest (push) Successful in 1m35s
Dependency update / dependency-update (push) Successful in 1m49s
Reviewed-on: #23
Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com>
Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
2026-05-16 09:33:40 -07:00
letteka 800d665c8b Merge pull request 'chore: dependency updates 2026-05-12' (#19) from dependency-updates-20260512 into main
CI / black (push) Successful in 1m28s
CI / ruff (push) Successful in 1m28s
CI / mypy (push) Successful in 1m42s
CI / pytest (push) Failing after 1m37s
Dependency update / dependency-update (push) Successful in 1m50s
Reviewed-on: #19
2026-05-15 16:10:24 -07:00
letteka c2faf2c81c Fixing ruff and mypy issues (#20)
Dependency update / dependency-update (push) Failing after 1m52s
CI / black (push) Successful in 1m31s
CI / ruff (push) Successful in 1m32s
CI / mypy (push) Successful in 1m41s
CI / pytest (push) Failing after 1m37s
Reviewed-on: #20
Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com>
Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
2026-05-12 16:42:02 -07:00
Gitea CI 49a81b5489 chore: update dependencies 2026-05-12
CI / black (pull_request) Failing after 1m31s
CI / ruff (pull_request) Successful in 1m30s
CI / mypy (pull_request) Successful in 1m44s
CI / pytest (pull_request) Failing after 1m36s
2026-05-12 22:45:09 +00:00
letteka fb1d7fec37 Fixing ruff and mypy issues (#18)
CI / black (push) Failing after 1m34s
CI / ruff (push) Successful in 1m31s
CI / mypy (push) Successful in 1m44s
CI / pytest (push) Failing after 1m42s
Dependency update / dependency-update (push) Successful in 1m51s
Reviewed-on: #18
Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com>
Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
2026-05-12 15:34:17 -07:00
letteka d8a0b544e5 chore: dependency updates 2026-05-12 (#17)
CI / black (push) Successful in 1m34s
CI / ruff (push) Failing after 1m34s
CI / mypy (push) Failing after 1m45s
CI / pytest (push) Successful in 1m39s
Dependency update / dependency-update (push) Failing after 1m51s
## Dependency update summary

Generated: 2026-05-12 17:08 UTC

### Vulnerability scan

Vulnerabilities found:

Name | Version | ID | Fix Versions
--- | --- | --- | ---
pytest | 8.4.2 | CVE-2025-71176 | 9.0.3

Name | Skip Reason
--- | ---
birdcam2 | Dependency not found on PyPI and could not be audited: birdcam2 (0.1.0)

### Outdated packages

The following packages were updated:

Was: version = "2.33.1"
Updated: version = "2.34.0"
---------

Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #17
2026-05-12 11:34:03 -07:00
letteka d12fe98c95 chore: dependency updates 2026-05-11 (#16)
CI / black (push) Successful in 1m33s
CI / ruff (push) Failing after 1m31s
CI / mypy (push) Failing after 1m43s
CI / pytest (push) Successful in 1m39s
Dependency update / dependency-update (push) Successful in 1m55s
## Dependency update summary

Generated: 2026-05-11 09:02 UTC

### Vulnerability scan

Vulnerabilities found:

Name | Version | ID | Fix Versions
--- | --- | --- | ---
pytest | 8.4.2 | CVE-2025-71176 | 9.0.3

Name | Skip Reason
--- | ---
birdcam2 | Dependency not found on PyPI and could not be audited: birdcam2 (0.1.0)

### Outdated packages

The following packages were updated:

Was: version = "7.13.5"
Updated: version = "7.14.0"
Was: version = "3.13"
Updated: version = "3.14"
Was: version = "0.9.0"
Updated: version = "0.11.0"
Was: version = "4.0.0"
Updated: version = "4.2.0"
Was: version = "0.3.35"
Updated: version = "0.3.36"
Was: version = "26.1"
Updated: version = "26.1.1"
Was: version = "2.6.3"
Updated: version = "2.7.0"
Was: python-versions = ">=3.9"
Updated: python-versions = ">=3.10"
---------

Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #16
2026-05-12 09:59:33 -07:00
letteka 8a9bf6d767 Merge pull request 'chore: dependency updates 2026-05-04' (#15) from dependency-updates-20260504 into main
CI / black (push) Successful in 2m20s
CI / ruff (push) Failing after 2m6s
CI / mypy (push) Failing after 2m16s
CI / pytest (push) Successful in 2m16s
Dependency update / dependency-update (push) Failing after 2m22s
Reviewed-on: #15
2026-05-04 10:14:37 -07:00
Gitea CI cea7c26264 chore: update dependencies 2026-05-04
CI / black (pull_request) Successful in 1m40s
CI / ruff (pull_request) Failing after 1m52s
CI / mypy (pull_request) Failing after 2m2s
CI / pytest (pull_request) Successful in 2m15s
2026-05-04 09:03:44 +00:00
letteka 29947974bb Merge pull request 'chore: dependency updates 2026-04-25' (#13) from dependency-updates-20260425 into main
Dependency update / dependency-update (push) Successful in 1m43s
CI / black (push) Successful in 1m17s
CI / ruff (push) Failing after 1m16s
CI / mypy (push) Failing after 1m27s
CI / pytest (push) Successful in 1m21s
Reviewed-on: #13
2026-04-25 12:08:03 -07:00
Gitea CI 80b9daa726 chore: update dependencies 2026-04-25
CI / black (pull_request) Successful in 1m18s
CI / mypy (pull_request) Failing after 1m27s
CI / pytest (pull_request) Successful in 1m21s
CI / ruff (pull_request) Failing after 1m16s
2026-04-25 16:16:37 +00:00
letteka f5c30e261c Adding a status db to better track camera status. (#12)
CI / black (push) Successful in 1m25s
CI / ruff (push) Failing after 1m17s
CI / pytest (push) Successful in 1m22s
Dependency update / dependency-update (push) Successful in 1m42s
CI / mypy (push) Failing after 1m25s
Reviewed-on: #12
Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com>
Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
2026-04-25 09:07:11 -07:00
letteka 76d9ace2b2 chore: dependency updates 2026-04-20 (#11)
CI / black (push) Successful in 1m19s
CI / ruff (push) Successful in 1m19s
CI / mypy (push) Successful in 1m23s
CI / pytest (push) Successful in 1m20s
Dependency update / dependency-update (push) Failing after 1m31s
## Dependency update summary

Generated: 2026-04-20 09:01 UTC

### Vulnerability scan

Vulnerabilities found:

Name | Version | ID | Fix Versions
--- | --- | --- | ---
pytest | 8.4.2 | CVE-2025-71176 | 9.0.3

Name | Skip Reason
--- | ---
birdcam2 | Dependency not found on PyPI and could not be audited: birdcam2 (0.1.0)

### Outdated packages

The following packages were updated:

Was: version = "17.0.0"
Updated: version = "17.0.1"
Was: version = "3.25.2"
Updated: version = "3.29.0"
Was: version = "3.4.9"
Updated: version = "3.4.10"
Was: version = "26.0"
Updated: version = "26.1"
Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #11
2026-04-20 12:19:51 -07:00
letteka 5d8b112928 chore: dependency updates 2026-04-13 (#10)
Dependency update / dependency-update (push) Successful in 1m38s
CI / black (push) Successful in 1m18s
CI / mypy (push) Successful in 1m22s
CI / pytest (push) Successful in 1m18s
CI / ruff (push) Successful in 1m19s
## Dependency update summary

Generated: 2026-04-13 09:01 UTC

### Vulnerability scan

No vulnerabilities found.

### Outdated packages

The following packages were updated:

Was: version = "0.8.1"
Updated: version = "0.9.0"
Was: version = "1.20.0"
Updated: version = "1.20.1"
Was: version = "4.9.4"
Updated: version = "4.9.6"
Was: version = "14.3.3"
Updated: version = "15.0.0"
Was: python-versions = ">=3.8.0"
Updated: python-versions = ">=3.9.0"
Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #10
2026-04-14 08:07:57 -07:00
letteka bd13e19e89 chore: dependency updates 2026-04-03 (#9)
Dependency update / dependency-update (push) Successful in 1m50s
CI / black (push) Successful in 1m19s
CI / ruff (push) Successful in 1m20s
CI / mypy (push) Successful in 1m27s
CI / pytest (push) Successful in 1m22s
## Dependency update summary

Generated: 2026-04-03 22:58 UTC

### Vulnerability scan

No vulnerabilities found.

### Outdated packages

The following packages were updated:

Was: version = "3.4.6"
Updated: version = "3.4.7"
Was: version = "8.3.1"
Updated: version = "8.3.2"
Was: version = "1.19.1"
Updated: version = "1.20.0"
Was: python-versions = ">=3.9"
Updated: python-versions = ">=3.10"
Was: librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""}
Updated: librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""}
Was: version = "3.4.8"
Updated: version = "3.4.9"
Was: version = "12.1.1"
Updated: version = "12.2.0"
Was: version = "3.1.7"
Updated: version = "3.1.8"
Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #9
2026-04-04 17:01:59 -07:00
letteka 07bd6277d5 chore: dependency updates 2026-03-30 (#8)
CI / black (push) Successful in 1m30s
CI / ruff (push) Successful in 1m18s
CI / mypy (push) Successful in 1m31s
CI / pytest (push) Successful in 1m19s
Dependency update / dependency-update (push) Successful in 1m40s
## Dependency update summary

Generated: 2026-03-30 21:46 UTC

### Vulnerability scan

No vulnerabilities found.

### Outdated packages

The following packages were updated:

Was: version = "2.33.0"
Updated: version = "2.33.1"
Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #8
2026-04-03 15:51:16 -07:00
letteka 30ad544172 chore: dependency updates 2026-03-30 (#7)
CI / black (push) Successful in 1m16s
CI / ruff (push) Successful in 1m15s
CI / mypy (push) Successful in 1m20s
CI / pytest (push) Successful in 1m17s
Dependency update / dependency-update (push) Successful in 1m33s
## Dependency update summary

Generated: 2026-03-30 09:01 UTC

### Vulnerability scan

Vulnerabilities found:

Name | Version | ID | Fix Versions
--- | --- | --- | ---
pygments | 2.19.2 | CVE-2026-4539 |

Name | Skip Reason
--- | ---
birdcam2 | Dependency not found on PyPI and could not be audited: birdcam2 (0.1.0)

### Outdated packages

The following packages were updated:

Was: version = "2.4.3"
Updated: version = "2.4.4"
Was: version = "2.19.2"
Updated: version = "2.20.0"
Was: python-versions = ">=3.8"
Updated: python-versions = ">=3.9"
Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #7
2026-03-30 14:39:45 -07:00
letteka 79057a91c6 chore: dependency updates 2026-03-27 (#6)
CI / black (push) Successful in 1m14s
CI / ruff (push) Successful in 1m16s
CI / mypy (push) Successful in 1m21s
CI / pytest (push) Successful in 1m15s
Dependency update / dependency-update (push) Successful in 1m34s
## Dependency update summary

Generated: 2026-03-27 00:30 UTC

### Vulnerability scan

Vulnerabilities found:

Name | Version | ID | Fix Versions
--- | --- | --- | ---
pygments | 2.19.2 | CVE-2026-4539 |

Name | Skip Reason
--- | ---
birdcam2 | Dependency not found on PyPI and could not be audited: birdcam2 (0.1.0)

### Outdated packages

The following packages were updated:

Was: version = "25.2.0"
Updated: version = "25.3.0"
Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #6
2026-03-26 18:51:59 -07:00
letteka 5520dd6287 chore: dependency updates 2026-03-26 (#5)
CI / black (push) Successful in 1m15s
CI / ruff (push) Successful in 1m17s
CI / mypy (push) Successful in 1m30s
CI / pytest (push) Successful in 1m26s
Dependency update / dependency-update (push) Successful in 1m33s
## Dependency update summary

Generated: 2026-03-26 23:25 UTC

### Vulnerability scan

Vulnerabilities found:

Name | Version | ID | Fix Versions
--- | --- | --- | ---
pygments | 2.19.2 | CVE-2026-4539 |
requests | 2.32.5 | CVE-2026-25645 | 2.33.0

Name | Skip Reason
--- | ---
birdcam2 | Dependency not found on PyPI and could not be audited: birdcam2 (0.1.0)

### Outdated packages

The following packages were updated:

Was: version = "25.1.0"
Updated: version = "25.2.0"
Was: version = "3.4.7"
Updated: version = "3.4.8"
Was: version = "2.32.5"
Updated: version = "2.33.0"
Was: python-versions = ">=3.9"
Updated: python-versions = ">=3.10"
Was: version = "2.4.0"
Updated: version = "2.4.1"
Was: version = "3.1.6"
Updated: version = "3.1.7"
Co-authored-by: Gitea CI <ci@gitea.com>
Reviewed-on: #5
2026-03-26 16:31:20 -07:00
letteka c62cb25d70 gitea_cicd (#4)
CI / ruff (push) Successful in 1m14s
CI / pytest (push) Successful in 1m17s
CI / black (push) Successful in 1m17s
CI / mypy (push) Successful in 1m21s
Dependency update / dependency-update (push) Successful in 1m33s
Reviewed-on: #4
Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com>
Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
2026-03-26 16:18:48 -07:00
letteka f5b0ddf063 changing to assignee (#3)
CI / black (push) Successful in 1m17s
CI / ruff (push) Successful in 1m17s
CI / mypy (push) Successful in 1m20s
CI / pytest (push) Successful in 1m16s
Dependency update / dependency-update (push) Failing after 1m31s
Reviewed-on: #3
2026-03-26 14:59:49 -07:00
letteka 2ed394aae7 Merge pull request 'adding quotes in create pr' (#2) from gitea_cicd into main
CI / black (push) Successful in 1m17s
CI / ruff (push) Successful in 1m19s
CI / mypy (push) Successful in 1m24s
CI / pytest (push) Successful in 1m20s
Dependency update / dependency-update (push) Failing after 1m34s
Reviewed-on: #2
2026-03-26 14:37:55 -07:00
letteka c7b85ee3b0 adding quotes in create pr
CI / black (pull_request) Successful in 1m18s
CI / ruff (pull_request) Successful in 1m17s
CI / mypy (pull_request) Successful in 1m23s
CI / pytest (pull_request) Successful in 1m19s
2026-03-26 14:36:28 -07:00
letteka 0fdcf5ca08 Merge pull request 'Adding Gitea CI/CD' (#1) from gitea_cicd into main
CI / black (push) Successful in 1m16s
CI / ruff (push) Successful in 1m17s
CI / mypy (push) Successful in 1m24s
CI / pytest (push) Successful in 1m18s
Dependency update / dependency-update (push) Failing after 1m33s
Reviewed-on: #1
2026-03-26 13:14:52 -07:00
letteka 637766a946 Updating runners to checkout
CI / black (pull_request) Successful in 1m16s
CI / ruff (pull_request) Successful in 1m17s
CI / mypy (pull_request) Successful in 1m24s
CI / pytest (pull_request) Successful in 1m19s
2026-03-26 13:08:02 -07:00
letteka b746cfc555 Adding Gitea CI/CD
CI / install (pull_request) Failing after 1m12s
CI / black (pull_request) Has been skipped
CI / ruff (pull_request) Has been skipped
CI / mypy (pull_request) Has been skipped
CI / pytest (pull_request) Has been skipped
2026-03-26 12:50:01 -07:00
letteka 147059108f Merge branch 'main' of gitlab.com:akettel/birdcam2 2026-03-20 08:34:52 -07:00
letteka 8a786d37e9 Updating black 2026-03-20 08:34:43 -07:00
letteka 373a3fbc8a Merge branch 'dependency-updates-20260320' into 'main'
chore: dependency updates 2026-03-20

See merge request akettel/birdcam2!4
2026-03-20 15:20:29 +00:00
CI/CD Token 153b1af208 chore: dependency updates 2026-03-20 2026-03-20 15:20:29 +00:00
letteka b4848f7a69 Merge branch 'dependency-updates-20260318' into 'main'
chore: dependency updates 2026-03-18

See merge request akettel/birdcam2!3
2026-03-18 22:03:46 +00:00
CI/CD Token 5097e753d5 chore: dependency updates 2026-03-18 2026-03-18 22:03:45 +00:00
letteka ffe1bcab74 Updating poetry.lock and adding execute to scripts. 2026-03-18 14:56:47 -07:00
letteka 0b1122b2e8 Merge branch 'dependency-updates-20260318' into 'main'
chore: dependency updates 2026-03-18

See merge request akettel/birdcam2!2
2026-03-18 21:37:21 +00:00
CI/CD Token 79b1c83d1a chore: dependency updates 2026-03-18 2026-03-18 21:37:21 +00:00
letteka 187b52d197 Adding MR assignment 2026-03-18 14:30:14 -07:00
17 changed files with 2248 additions and 924 deletions
+94
View File
@@ -0,0 +1,94 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 9 * * 1"
jobs:
black:
runs-on: ubuntu-latest
container: python:3.13-slim
steps:
- name: Checkout
run: |
apt-get update -qq && apt-get install -y -qq git
git clone ${{ github.server_url }}/${{ github.repository }} .
git checkout ${{ github.sha }}
- name: Install system dependencies
run: apt-get install -y -qq libcap-dev
- name: Install Poetry and dependencies
run: |
pip install poetry==2.1.1
poetry install --without pi --no-interaction
- name: Run black
run: poetry run black --check src/ tests/
ruff:
runs-on: ubuntu-latest
container: python:3.13-slim
steps:
- name: Checkout
run: |
apt-get update -qq && apt-get install -y -qq git
git clone ${{ github.server_url }}/${{ github.repository }} .
git checkout ${{ github.sha }}
- name: Install system dependencies
run: apt-get install -y -qq libcap-dev
- name: Install Poetry and dependencies
run: |
pip install poetry==2.1.1
poetry install --without pi --no-interaction
- name: Run ruff
run: poetry run ruff check src/ tests/
mypy:
runs-on: ubuntu-latest
container: python:3.13-slim
steps:
- name: Checkout
run: |
apt-get update -qq && apt-get install -y -qq git
git clone ${{ github.server_url }}/${{ github.repository }} .
git checkout ${{ github.sha }}
- name: Install system dependencies
run: apt-get install -y -qq libcap-dev
- name: Install Poetry and dependencies
run: |
pip install poetry==2.1.1
poetry install --without pi --no-interaction
- name: Run mypy
run: poetry run mypy src/
pytest:
runs-on: ubuntu-latest
container: python:3.13-slim
steps:
- name: Checkout
run: |
apt-get update -qq && apt-get install -y -qq git
git clone ${{ github.server_url }}/${{ github.repository }} .
git checkout ${{ github.sha }}
- name: Install system dependencies
run: apt-get install -y -qq libcap-dev
- name: Install Poetry and dependencies
run: |
pip install poetry==2.1.1
poetry install --without pi --no-interaction
- name: Run pytest
run: poetry run pytest
+46
View File
@@ -0,0 +1,46 @@
name: Dependency update
on:
push:
branches: [main]
schedule:
- cron: "0 9 * * 1"
jobs:
dependency-update:
runs-on: ubuntu-latest
container: python:3.13-slim
if: "!startsWith(github.ref, 'refs/heads/dependency-updates-')"
steps:
- name: Checkout
run: |
apt-get update -qq && apt-get install -y -qq git curl
git clone ${{ github.server_url }}/${{ github.repository }} .
git checkout ${{ github.sha }}
- name: Install system dependencies
run: apt-get install -y -qq libcap-dev
- name: Install Poetry and dependencies
run: |
pip install poetry==2.1.1
poetry install --without pi --no-interaction
- name: Run dependency update check
id: update
run: |
chmod +x scripts/dependency_update.sh
set +e
bash scripts/dependency_update.sh
echo "changes=$?" >> $GITHUB_OUTPUT
- name: Create pull request
if: steps.update.outputs.changes == '1'
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
CI_SERVER_URL: ${{ secrets.CI_SERVER_URL }}
REPO: ${{ github.repository }}
CI_ASSIGNEE_ID: ${{ secrets.CI_ASSIGNEE_ID }}
run: |
chmod +x scripts/create_pr_gitea.sh
bash scripts/create_pr_gitea.sh
+3
View File
@@ -11,3 +11,6 @@ venv/
coverage.xml coverage.xml
.coverage .coverage
htmlcov/ htmlcov/
birdcam.db
.vscode/
.ruff_cache/
+96 -83
View File
@@ -1,93 +1,106 @@
# birdcam2 # BirdCam: A Flask-Based Camera Monitoring System
A lightweight, Python-based system for monitoring and managing Raspberry Pi cameras using Flask, SQLAlchemy, and modern Python best practices.
## 🚀 Overview
## Getting started BirdCam is a simple yet robust application designed to monitor camera status and log events in real time. Built with Flask and SQLAlchemy, it provides:
- Real-time camera status tracking (`running` state).
- Event logging for camera actions (`start`, `stop`) with IP addresses and timestamps.
- A clean, maintainable architecture using modern Python 3.12 features and type hints.
To make it easy for you to get started with GitLab, here's a list of recommended next steps. It's ideal for hobbyists, developers, or IoT projects where you need to track camera activity from a central dashboard or script.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! ## 🔧 Key Features
## Add your files - ✅ Real-time camera status (e.g., whether a camera is running or not).
- ✅ Event logging with full metadata (action, IP address, timestamp).
- ✅ Automatic creation of status records if missing.
- ✅ Type-safe, well-documented code with full type hints.
- ✅ Modern Python practices:
- Uses Python 3.12 with `__future__.annotations`.
- Leverages `dataclasses`-like patterns with `Mapped` and `mapped_column`.
- Includes comprehensive linting via **Black**, **Ruff**, and **Mypy**.
- ✅ Automated test coverage with `pytest` and `pytest-cov`.
* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files ## 📦 Dependencies
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
``` | Package | Version | Purpose |
cd existing_repo |----------------------|---------------|------------------------------------------|
git remote add origin https://gitlab.com/akettel/birdcam2.git | `Flask` | `^3.0` | Web framework for serving the app |
git branch -M main | `Flask-SQLAlchemy` | `^3.1.1` | ORM for database interactions |
git push -uf origin main | `Pillow` | `^12.1.1` | Image processing (for camera previews) |
| `Gunicorn` | `^25.1.0` | Production WSGI server |
| `Pytest` | `^9.0.3` | Testing framework |
| `Pytest-Cov` | `^7.1.0` | Test coverage reporting |
| `Black` / `Ruff` | `^26.0` / `^0.4` | Code formatting and linting |
| `Mypy` | `^1.10` | Static type checking |
> ✅ All dependencies are pinned for stability and compatibility.
## 🛠️ Setup & Installation
1. Clone the repository:
```bash
git clone https://gitea.letteka.com/letteka/birdcam.git
cd birdcam
```
2. Install dependencies:
```bash
pip install -e .
```
3. Run the app:
```bash
gunicorn -w 1 -b 0.0.0.0:5000 app:app
```
Or use Flasks built-in dev server:
```bash
flask run
```
## 📝 How It Works
### Camera Status
- The `CameraStatus` model tracks a single cameras running state.
- On startup, it ensures a status record exists (ID = 1).
- The `get()` and `set_running()` methods allow safe access and updates.
### Camera Events
- Every time a camera action occurs (`start` or `stop`), an event is logged.
- Events include:
- Action type (`start` / `stop`)
- Client IP address
- Timestamp with timezone support
- The `recent()` method allows retrieval of the latest events (e.g., for logging or dashboard display).
## 🧪 Testing
The project includes full test coverage using `pytest` with coverage reporting:
```bash
pytest --cov=src --cov-report=term-missing
``` ```
## Integrate with your tools Tests are located in the tests/ directory and cover:
- Status retrieval and updates.
* [Set up project integrations](https://gitlab.com/akettel/birdcam2/-/settings/integrations) - Event logging and retrieval.
- Edge cases (e.g., missing status records).
## Collaborate with your team ## 📝 Development Workflow
✅ Code is formatted with Black and linted with Ruff.
* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/) ✅ Static type checking is enforced using Mypy.
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/) ✅ All code uses consistent naming and formatting.
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically) ✅ All public APIs are documented with docstrings.
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/) ## ⚙️ Future Improvements
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) - Add support for multiple cameras.
- Integrate with a web dashboard (e.g., using Vue.js or Streamlit).
## Test and Deploy - Add camera preview functionality via HTTP endpoints.
- Support for MQTT or other event-driven systems.
Use the built-in continuous integration in GitLab. - Add user authentication or role-based access.
## 📚 License
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/) This project is open-source and available under the MIT License.
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/) ## 🙌 Contributing
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/) Contributions are welcome! Please open an issue or submit a pull request with clear descriptions and tests.
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/) 📬 Contact: Andrew Kettel andrew.kettel@gmail.com
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
Generated
+1116 -767
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -11,15 +11,16 @@ python = "^3.12"
flask = "^3.0" flask = "^3.0"
pillow = "^12.1.1" pillow = "^12.1.1"
gunicorn = "^25.1.0" gunicorn = "^25.1.0"
flask-sqlalchemy = "^3.1.1"
[tool.poetry.group.pi.dependencies] [tool.poetry.group.pi.dependencies]
picamera2 = "^0.3" picamera2 = "^0.3"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.0" pytest = "^9.0.3"
pytest-cov = "^5.0" pytest-cov = "^7.1.0"
pytest-flask = "^1.3" pytest-flask = "^1.3"
black = "^24.0" black = "^26.5.0"
ruff = "^0.4" ruff = "^0.4"
mypy = "^1.10" mypy = "^1.10"
pip-audit = "^2.10.0" pip-audit = "^2.10.0"
Regular → Executable
+2
View File
@@ -28,6 +28,8 @@ curl --fail --silent --show-error \
\"title\": \"chore: dependency updates $(date +%Y-%m-%d)\", \"title\": \"chore: dependency updates $(date +%Y-%m-%d)\",
\"description\": $(echo "$DESCRIPTION" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'), \"description\": $(echo "$DESCRIPTION" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
\"labels\": \"dependencies\", \"labels\": \"dependencies\",
\"assignee_ids\": [${GITLAB_ASSIGNEE_ID}],
\"reviewer_ids\": [${GITLAB_ASSIGNEE_ID}],
\"remove_source_branch\": true \"remove_source_branch\": true
}" \ }" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests"
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
set -e
BRANCH="dependency-updates-$(date +%Y%m%d)"
SUMMARY_FILE="/tmp/update_summary.md"
git config user.email "ci@gitea.com"
git config user.name "Gitea CI"
git checkout -b "$BRANCH"
git add pyproject.toml poetry.lock
git commit -m "chore: update dependencies $(date +%Y-%m-%d)"
git push "https://oauth2:${CI_TOKEN}@${CI_SERVER_URL#https://}/${REPO}.git" "$BRANCH"
# build the entire JSON payload in Python to avoid shell escaping issues
PAYLOAD=$(python3 - <<EOF
import json, sys
with open("${SUMMARY_FILE}") as f:
description = f.read()
payload = {
"head": "${BRANCH}",
"base": "main",
"title": "chore: dependency updates $(date +%Y-%m-%d)",
"body": description,
"assignees": [${ASSIGNEE_ID}],
}
print(json.dumps(payload))
EOF
)
HTTP_RESPONSE=$(curl --silent --show-error \
--write-out "HTTPSTATUS:%{http_code}" \
--request POST \
--header "Authorization: token ${CI_TOKEN}" \
--header "Content-Type: application/json" \
--data "$PAYLOAD" \
"${CI_SERVER_URL}/api/v1/repos/${REPO}/pulls")
HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed -e 's/HTTPSTATUS:.*//g')
HTTP_STATUS=$(echo "$HTTP_RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
echo "API response status: $HTTP_STATUS"
echo "API response body: $HTTP_BODY"
if [ "$HTTP_STATUS" -ne 201 ]; then
echo "Failed to create pull request"
exit 1
fi
echo "Pull request created for branch: $BRANCH"
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+141 -9
View File
@@ -1,18 +1,60 @@
from __future__ import annotations
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from flask import Flask, Response, abort, jsonify, render_template, send_from_directory from flask import (
Flask,
Response,
abort,
jsonify,
render_template,
request,
send_from_directory,
)
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from src.camera import camera from src.camera import RECORDINGS_DIR, camera
from src.database import CameraEvent, CameraRecordingEvent, CameraStatus, db
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2, x_proto=2, x_host=2) # type: ignore[method-assign]
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)
def create_app() -> Flask:
flask_app = Flask(__name__, template_folder="templates")
flask_app.wsgi_app = ProxyFix( # type: ignore[method-assign]
flask_app.wsgi_app, x_for=2, x_proto=2, x_host=2
)
flask_app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///birdcam.db"
flask_app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(flask_app)
with flask_app.app_context():
db.create_all()
# sync camera state with db on startup
status = CameraStatus.get()
if status.running and not camera.running:
CameraStatus.set_running(False)
return flask_app
app = create_app()
def get_client_ip() -> str:
return (
request.headers.get("X-Forwarded-For", request.remote_addr or "unknown")
.split(",")[0]
.strip()
)
# ── Health ───────────────────────────────────────────────────────────────────
@app.get("/heartbeat") @app.get("/heartbeat")
def heartbeat() -> tuple[Response, int]: def heartbeat() -> tuple[Response, int]:
return ( return (
@@ -26,43 +68,85 @@ def heartbeat() -> tuple[Response, int]:
) )
# ── Pages ────────────────────────────────────────────────────────────────────
@app.get("/") @app.get("/")
def index() -> str: def index() -> str:
return render_template("index.html") return render_template("index.html")
# ── Stream endpoints ──────────────────────────────────────────────────────────
@app.post("/camera/start") @app.post("/camera/start")
def camera_start() -> tuple[Response, int]: def camera_start() -> tuple[Response, int]:
status = CameraStatus.get()
if status.running:
return jsonify({"status": "already_running"}), 200
camera.start() camera.start()
CameraStatus.set_running(True)
CameraEvent.log("start", get_client_ip())
return jsonify({"status": "started"}), 200 return jsonify({"status": "started"}), 200
@app.post("/camera/stop") @app.post("/camera/stop")
def camera_stop() -> tuple[Response, int]: def camera_stop() -> tuple[Response, int]:
status = CameraStatus.get()
if not status.running:
return jsonify({"status": "already_stopped"}), 200
camera.stop() camera.stop()
CameraStatus.set_running(False)
CameraEvent.log("stop", get_client_ip())
return jsonify({"status": "stopped"}), 200 return jsonify({"status": "stopped"}), 200
@app.get("/camera/status") @app.get("/camera/status")
def camera_status() -> tuple[Response, int]: def camera_status() -> tuple[Response, int]:
status = CameraStatus.get()
rec = camera.recording_status()
return ( return (
jsonify( jsonify(
{ {
"running": camera.running, "running": status.running,
"ready": camera.wait_until_ready(timeout=0), "ready": (
camera.wait_until_ready(timeout=0) if status.running else False
),
"updated_at": status.updated_at.isoformat(),
"recording": rec["recording"],
"recording_started_at": rec["started_at"],
} }
), ),
200, 200,
) )
@app.get("/camera/log")
def camera_log() -> tuple[Response, int]:
events = CameraEvent.recent(limit=50)
return (
jsonify(
[
{
"action": e.action,
"ip_address": e.ip_address,
"timestamp": e.timestamp.isoformat(),
}
for e in events
]
),
200,
)
@app.get("/camera/hls/<path:filename>") @app.get("/camera/hls/<path:filename>")
def hls_segment(filename: str) -> Response: def hls_segment(filename: str) -> Response:
hls_dir = camera.hls_dir hls_dir = camera.hls_dir
if not hls_dir.exists(): if not hls_dir.exists():
abort(404) abort(404)
# set correct MIME types for HLS files
if filename.endswith(".m3u8"): if filename.endswith(".m3u8"):
mimetype = "application/vnd.apple.mpegurl" mimetype = "application/vnd.apple.mpegurl"
elif filename.endswith(".ts"): elif filename.endswith(".ts"):
@@ -73,5 +157,53 @@ def hls_segment(filename: str) -> Response:
return send_from_directory(str(hls_dir), filename, mimetype=mimetype) return send_from_directory(str(hls_dir), filename, mimetype=mimetype)
# ── Recording endpoints ───────────────────────────────────────────────────────
@app.post("/camera/record/start")
def record_start() -> tuple[Response, int]:
if camera.recording:
return jsonify({"status": "already_recording"}), 200
try:
path = camera.start_recording()
CameraRecordingEvent.log("record_start", get_client_ip(), str(path))
return jsonify({"status": "recording", "path": str(path)}), 200
except RuntimeError as exc:
return jsonify({"status": "error", "message": str(exc)}), 409
@app.post("/camera/record/stop")
def record_stop() -> tuple[Response, int]:
if not camera.recording:
return jsonify({"status": "not_recording"}), 200
path = camera.stop_recording()
filename = Path(path).name if path else None
CameraRecordingEvent.log("record_stop", get_client_ip(), str(path))
return jsonify({"status": "stopped", "filename": filename}), 200
@app.get("/camera/recordings")
def list_recordings() -> tuple[Response, int]:
recordings = camera.list_recordings()
return jsonify(recordings), 200
@app.get("/camera/recordings/<filename>")
def download_recording(filename: str) -> Response:
# safety: only allow the expected filename pattern
if not filename.startswith("recording_") or not filename.endswith(".mp4"):
abort(404)
if not RECORDINGS_DIR.exists():
abort(404)
return send_from_directory(
str(RECORDINGS_DIR),
filename,
mimetype="video/mp4",
as_attachment=True,
)
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True) app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)
+194 -15
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
import io import io
import logging import logging
import shutil import shutil
import subprocess import subprocess
import threading import threading
import time import time
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,6 +22,7 @@ except ImportError:
PICAMERA_AVAILABLE = False PICAMERA_AVAILABLE = False
HLS_DIR = Path("/tmp/hls") HLS_DIR = Path("/tmp/hls")
RECORDINGS_DIR = Path("/var/lib/birdcam/recordings")
SEGMENT_DURATION = 2 # seconds per segment SEGMENT_DURATION = 2 # seconds per segment
SEGMENT_COUNT = 5 # segments to keep in playlist SEGMENT_COUNT = 5 # segments to keep in playlist
BITRATE = 2_000_000 # 2 Mbps — adjust for bandwidth needs BITRATE = 2_000_000 # 2 Mbps — adjust for bandwidth needs
@@ -47,13 +51,21 @@ class Camera:
def __init__(self) -> None: def __init__(self) -> None:
self._picam: Picamera2 | None = None self._picam: Picamera2 | None = None
self._encoder: H264Encoder | None = None self._stream_encoder: H264Encoder | None = None
self._record_encoder: H264Encoder | None = None
self._ffmpeg: subprocess.Popen[bytes] | None = None self._ffmpeg: subprocess.Popen[bytes] | None = None
self._record_ffmpeg: subprocess.Popen[bytes] | None = None
self._output: PipeOutput | None = None self._output: PipeOutput | None = None
self._ready_event = threading.Event() self._ready_event = threading.Event()
self._watch_thread: threading.Thread | None = None self._watch_thread: threading.Thread | None = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
self.running = False self.running = False
self.recording = False
self._current_recording_path: Path | None = None
self._recording_started_at: datetime | None = None
self._lock = threading.Lock()
# ── Streaming ────────────────────────────────────────────────────────────
def start(self) -> None: def start(self) -> None:
if self.running: if self.running:
@@ -65,7 +77,7 @@ class Camera:
HLS_DIR.mkdir(parents=True) HLS_DIR.mkdir(parents=True)
if not PICAMERA_AVAILABLE: if not PICAMERA_AVAILABLE:
logger.info("Mock camera started") logger.info("Mock camera started (picamera2 not available)")
self.running = True self.running = True
return return
@@ -75,11 +87,11 @@ class Camera:
"-loglevel", "-loglevel",
"warning", "warning",
"-f", "-f",
"h264", # input is raw H.264 "h264",
"-i", "-i",
"pipe:0", # read from stdin "pipe:0",
"-c:v", "-c:v",
"copy", # no re-encoding — pass through directly "copy",
"-hls_time", "-hls_time",
str(SEGMENT_DURATION), str(SEGMENT_DURATION),
"-hls_list_size", "-hls_list_size",
@@ -99,7 +111,6 @@ class Camera:
) )
self._output = PipeOutput(self._ffmpeg) self._output = PipeOutput(self._ffmpeg)
# configure picamera2 with H.264 encoder
self._picam = Picamera2() self._picam = Picamera2()
config = self._picam.create_video_configuration( config = self._picam.create_video_configuration(
main={"size": (1280, 720)}, main={"size": (1280, 720)},
@@ -107,19 +118,18 @@ class Camera:
self._picam.configure(config) self._picam.configure(config)
self._picam.set_controls( self._picam.set_controls(
{ {
"Brightness": 0.1, # -1.0 to 1.0, default 0.0 "Brightness": 0.1,
"Contrast": 1.1, # 0.0 to 32.0, default 1.0 "Contrast": 1.1,
"Saturation": 1.1, # 0.0 to 32.0, default 1.0 "Saturation": 1.1,
"Sharpness": 1.0, # 0.0 to 16.0, default 1.0 "Sharpness": 1.0,
"AwbEnable": True, # auto white balance "AwbEnable": True,
"AeEnable": True, # auto exposure "AeEnable": True,
} }
) )
self._encoder = H264Encoder(bitrate=BITRATE) self._stream_encoder = H264Encoder(bitrate=BITRATE)
buffered = io.BufferedWriter(self._output) buffered = io.BufferedWriter(self._output)
self._picam.start_recording(self._encoder, FileOutput(buffered)) self._picam.start_recording(self._stream_encoder, FileOutput(buffered))
# watch for the playlist to appear — signals first segment is ready
self._stop_event.clear() self._stop_event.clear()
self._ready_event.clear() self._ready_event.clear()
self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True) self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True)
@@ -147,6 +157,11 @@ class Camera:
def stop(self) -> None: def stop(self) -> None:
if not self.running: if not self.running:
return return
# stop any active recording first
if self.recording:
self.stop_recording()
self._stop_event.set() self._stop_event.set()
self._ready_event.set() # unblock any waiters self._ready_event.set() # unblock any waiters
@@ -174,6 +189,170 @@ class Camera:
self.running = False self.running = False
logger.info("Camera stopped") logger.info("Camera stopped")
# ── Recording ────────────────────────────────────────────────────────────
def start_recording(self) -> Path:
"""
Start recording to an MP4 file. Can run independently of or
simultaneously with the HLS stream. Returns the output file path.
"""
with self._lock:
if self.recording:
raise RuntimeError("Already recording")
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
output_path = RECORDINGS_DIR / f"recording_{timestamp}.mp4"
if not PICAMERA_AVAILABLE:
# mock mode — create an empty placeholder file
output_path.touch()
self._current_recording_path = output_path
self._recording_started_at = datetime.now(UTC)
self.recording = True
logger.info("Mock recording started: %s", output_path)
return output_path
if self._picam is None:
# camera not yet streaming — start it temporarily in record-only mode
self._picam = Picamera2()
config = self._picam.create_video_configuration(
main={"size": (1280, 720)},
)
self._picam.configure(config)
self._picam.set_controls(
{
"Brightness": 0.1,
"Contrast": 1.1,
"Saturation": 1.1,
"Sharpness": 1.0,
"AwbEnable": True,
"AeEnable": True,
}
)
# pipe raw H.264 → ffmpeg → MP4 container
ffmpeg_cmd = [
"ffmpeg",
"-loglevel",
"warning",
"-f",
"h264",
"-i",
"pipe:0",
"-c:v",
"copy",
"-movflags",
"+faststart",
str(output_path),
]
self._record_ffmpeg = subprocess.Popen(
ffmpeg_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
record_pipe = PipeOutput(self._record_ffmpeg)
record_buffered = io.BufferedWriter(record_pipe)
self._record_encoder = H264Encoder(bitrate=BITRATE)
if self.running:
# camera already running for streaming — add a second encoder output
self._picam.start_encoder(
self._record_encoder,
FileOutput(record_buffered),
)
else:
# no stream active — start camera just for recording
self._picam.start_recording(
self._record_encoder,
FileOutput(record_buffered),
)
self._current_recording_path = output_path
self._recording_started_at = datetime.now(UTC)
self.recording = True
logger.info("Recording started: %s", output_path)
return output_path
def stop_recording(self) -> Path | None:
"""Stop the active recording and finalise the MP4 file."""
with self._lock:
if not self.recording:
return None
path = self._current_recording_path
if PICAMERA_AVAILABLE and self._record_encoder is not None:
if self.running:
# streaming still active — only stop the recording encoder
self._picam.stop_encoder(self._record_encoder) # type: ignore[union-attr]
else:
# recording-only mode — stop the whole camera
if self._picam:
self._picam.stop_recording()
self._picam.close()
self._picam = None
self._record_encoder = None
if self._record_ffmpeg:
if self._record_ffmpeg.stdin:
self._record_ffmpeg.stdin.close()
try:
self._record_ffmpeg.wait(timeout=10)
except subprocess.TimeoutExpired:
self._record_ffmpeg.kill()
self._record_ffmpeg = None
self.recording = False
self._current_recording_path = None
self._recording_started_at = None
logger.info("Recording stopped: %s", path)
return path
def recording_status(self) -> dict[str, object]:
"""Return current recording state for the API."""
return {
"recording": self.recording,
"path": (
str(self._current_recording_path)
if self._current_recording_path
else None
),
"started_at": (
self._recording_started_at.isoformat()
if self._recording_started_at
else None
),
}
def list_recordings(self) -> list[dict[str, object]]:
"""Return metadata for all saved recordings, newest first."""
if not RECORDINGS_DIR.exists():
return []
recordings = []
for f in sorted(RECORDINGS_DIR.glob("recording_*.mp4"), reverse=True):
stat = f.stat()
recordings.append(
{
"filename": f.name,
"size_bytes": stat.st_size,
"created_at": datetime.fromtimestamp(
stat.st_ctime, tz=UTC
).isoformat(),
"duration_seconds": None, # could be derived via ffprobe if needed
}
)
return recordings
# ── Properties ───────────────────────────────────────────────────────────
@property @property
def hls_dir(self) -> Path: def hls_dir(self) -> Path:
return HLS_DIR return HLS_DIR
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import UTC, datetime
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
db: SQLAlchemy = SQLAlchemy(model_class=Base)
class CameraStatus(Base):
__tablename__ = "camera_status"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
running: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
)
@staticmethod
def get() -> CameraStatus:
"""Get the single status row, creating it if it doesn't exist."""
status = db.session.get(CameraStatus, 1)
if status is None:
status = CameraStatus(id=1, running=False)
db.session.add(status)
db.session.commit()
return status
@staticmethod
def set_running(running: bool) -> CameraStatus:
status = CameraStatus.get()
status.running = running
status.updated_at = datetime.now(UTC)
db.session.commit()
return status
class CameraEvent(Base):
__tablename__ = "camera_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
action: Mapped[str] = mapped_column(String(10), nullable=False) # 'start' | 'stop'
ip_address: Mapped[str] = mapped_column(String(45), nullable=False)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
)
@staticmethod
def log(action: str, ip_address: str) -> CameraEvent:
event = CameraEvent(action=action, ip_address=ip_address)
db.session.add(event)
db.session.commit()
return event
@staticmethod
def recent(limit: int = 50) -> list[CameraEvent]:
return list(
db.session.execute(
db.select(CameraEvent)
.order_by(CameraEvent.timestamp.desc())
.limit(limit)
)
.scalars()
.all()
)
class CameraRecordingEvent(Base):
"""Audit log for recording start/stop actions."""
__tablename__ = "camera_recording_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
action: Mapped[str] = mapped_column(
String(20), nullable=False
) # 'record_start' | 'record_stop'
ip_address: Mapped[str] = mapped_column(String(45), nullable=False)
file_path: Mapped[str] = mapped_column(String(512), nullable=False, default="")
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
)
@staticmethod
def log(action: str, ip_address: str, file_path: str = "") -> CameraRecordingEvent:
event = CameraRecordingEvent(
action=action,
ip_address=ip_address,
file_path=file_path,
)
db.session.add(event)
db.session.commit()
return event
@staticmethod
def recent(limit: int = 50) -> list[CameraRecordingEvent]:
return list(
db.session.execute(
db.select(CameraRecordingEvent)
.order_by(CameraRecordingEvent.timestamp.desc())
.limit(limit)
)
.scalars()
.all()
)
+262 -41
View File
@@ -44,6 +44,24 @@
justify-content: center; justify-content: center;
} }
#preview.is-recording {
outline: 2px solid #ef4444;
outline-offset: 2px;
animation: rec-pulse 2s ease-in-out infinite;
}
@keyframes rec-pulse {
0%,
100% {
outline-color: #ef4444;
}
50% {
outline-color: #7f1d1d;
}
}
#stream-video { #stream-video {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -54,6 +72,10 @@
#placeholder { #placeholder {
color: #555; color: #555;
font-size: 0.9rem; font-size: 0.9rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
} }
.controls { .controls {
@@ -70,7 +92,7 @@
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: opacity 0.2s; transition: opacity 0.2s, background 0.2s;
} }
button:disabled { button:disabled {
@@ -88,11 +110,128 @@
color: #fff; color: #fff;
} }
#btn-record {
background: #3f3f3f;
color: #f0f0f0;
display: flex;
align-items: center;
gap: 0.5rem;
}
#btn-record.is-recording {
background: #b91c1c;
color: #fff;
}
.rec-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
#btn-record.is-recording .rec-dot {
animation: blink 1s step-start infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.badge {
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-weight: 600;
letter-spacing: 0.03em;
}
.badge.live {
background: #dc2626;
color: #fff;
}
#status { #status {
font-size: 0.85rem; font-size: 0.85rem;
color: #888; color: #888;
min-height: 1.2em; min-height: 1.2em;
} }
#recordings-section {
width: 100%;
max-width: 720px;
}
#recordings-section h2 {
font-size: 0.9rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #666;
margin-bottom: 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid #222;
}
#recordings-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.recording-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
gap: 1rem;
}
.recording-item .rec-name {
font-family: monospace;
color: #ccc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.recording-item .rec-meta {
color: #555;
white-space: nowrap;
font-size: 0.75rem;
}
.recording-item a {
color: #22c55e;
text-decoration: none;
font-size: 0.78rem;
white-space: nowrap;
transition: color 0.15s;
}
.recording-item a:hover {
color: #4ade80;
}
#recordings-empty {
color: #444;
font-size: 0.82rem;
text-align: center;
padding: 0.75rem 0;
}
</style> </style>
</head> </head>
@@ -101,71 +240,165 @@
<div id="preview"> <div id="preview">
<div id="placeholder"> <div id="placeholder">
<div class="dot" id="status-dot"></div>
<span id="placeholder-text">Checking stream...</span> <span id="placeholder-text">Checking stream...</span>
</div> </div>
<video id="stream-video" autoplay muted playsinline></video> <video id="stream-video" autoplay muted playsinline></video>
</div> </div>
<div class="controls" id="controls"></div> <div class="controls" id="controls"></div>
<p id="status"></p> <p id="status"></p>
<!-- hls.js from CDN --> <div id="recordings-section">
<h2>Recordings</h2>
<div id="recordings-list">
<p id="recordings-empty">No recordings yet.</p>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
<script> <script>
const video = document.getElementById("stream-video"); const video = document.getElementById("stream-video");
const placeholder = document.getElementById("placeholder"); const placeholder = document.getElementById("placeholder");
const placeholderText = document.getElementById("placeholder-text"); const placeholderText = document.getElementById("placeholder-text");
const statusDot = document.getElementById("status-dot");
const controls = document.getElementById("controls"); const controls = document.getElementById("controls");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const preview = document.getElementById("preview");
const recordingsList = document.getElementById("recordings-list");
let hls = null; let hls = null;
let pollInterval = null; let pollInterval = null;
let isRecording = false;
function setStatus(msg) { statusEl.textContent = msg; }
function fmtBytes(b) {
if (b < 1024) return b + " B";
if (b < 1048576) return (b / 1024).toFixed(1) + " KB";
return (b / 1048576).toFixed(1) + " MB";
}
function fmtDate(iso) { return new Date(iso).toLocaleString(); }
// ── Recordings list ─────────────────────────────────────────────────────
async function refreshRecordings() {
const res = await fetch("/camera/recordings");
const list = await res.json();
recordingsList.innerHTML = "";
if (list.length === 0) {
recordingsList.innerHTML = '<p id="recordings-empty">No recordings yet.</p>';
return;
}
list.forEach(rec => {
const item = document.createElement("div");
item.className = "recording-item";
item.innerHTML =
'<span class="rec-name">' + rec.filename + '</span>' +
'<span class="rec-meta">' + fmtBytes(rec.size_bytes) + ' &nbsp;&middot;&nbsp; ' + fmtDate(rec.created_at) + '</span>' +
'<a href="/camera/recordings/' + rec.filename + '" download>Download</a>';
recordingsList.appendChild(item);
});
}
// ── Record button ───────────────────────────────────────────────────────
function createRecordButton() {
const btn = document.createElement("button");
btn.id = "btn-record";
const dot = document.createElement("span");
dot.className = "rec-dot";
const label = document.createElement("span");
label.className = "rec-label";
if (isRecording) {
btn.classList.add("is-recording");
label.textContent = "Stop Recording";
} else {
label.textContent = "Record";
}
btn.appendChild(dot);
btn.appendChild(label);
btn.addEventListener("click", toggleRecording);
return btn;
}
async function toggleRecording() {
const btn = document.getElementById("btn-record");
if (btn) btn.disabled = true;
if (!isRecording) {
setStatus("Starting recording...");
const res = await fetch("/camera/record/start", { method: "POST" });
const data = await res.json();
if (data.status === "recording" || data.status === "already_recording") {
isRecording = true;
setStatus("Recording");
} else {
setStatus("Failed to start recording: " + (data.message || "unknown error"));
}
} else {
setStatus("Stopping recording...");
await fetch("/camera/record/stop", { method: "POST" });
isRecording = false;
setStatus("Recording saved");
await refreshRecordings();
}
const streaming = !!document.getElementById("btn-stop");
renderControls(streaming);
}
// ── Controls ────────────────────────────────────────────────────────────
function renderControls(running) { function renderControls(running) {
controls.innerHTML = ""; controls.innerHTML = "";
if (running) { if (running) {
const stop = document.createElement("button"); const stop = document.createElement("button");
stop.id = "btn-stop"; stop.id = "btn-stop";
stop.textContent = "Stop"; stop.textContent = "Stop Stream";
stop.addEventListener("click", stopStream); stop.addEventListener("click", stopStream);
controls.appendChild(stop); controls.appendChild(stop);
const badge = document.createElement("span");
badge.className = "badge live";
badge.textContent = "● Live";
controls.appendChild(badge);
} else { } else {
const start = document.createElement("button"); const start = document.createElement("button");
start.id = "btn-start"; start.id = "btn-start";
start.textContent = "Start"; start.textContent = "Start Stream";
start.addEventListener("click", startStream); start.addEventListener("click", startStream);
controls.appendChild(start); controls.appendChild(start);
} }
controls.appendChild(createRecordButton());
if (running) {
const badge = document.createElement("span");
badge.className = "badge live";
badge.textContent = "Live";
controls.appendChild(badge);
}
if (isRecording) {
preview.classList.add("is-recording");
} else {
preview.classList.remove("is-recording");
}
} }
function showOffline(message = "Stream is offline") { // ── Video display ────────────────────────────────────────────────────────
function showOffline(message) {
video.style.display = "none"; video.style.display = "none";
video.src = ""; video.src = "";
placeholder.style.display = "flex"; placeholder.style.display = "flex";
placeholderText.textContent = message; placeholderText.textContent = message || "Stream is offline";
statusDot.classList.remove("live");
} }
function showLive() { function showLive() {
placeholder.style.display = "none"; placeholder.style.display = "none";
video.style.display = "block"; video.style.display = "block";
statusDot.classList.add("live");
} }
// ── HLS ───────────────────────────────────────────────────────────────── // ── HLS ─────────────────────────────────────────────────────────────────
function startHls() { function startHls() {
const src = "/camera/hls/stream.m3u8"; const src = "/camera/hls/stream.m3u8";
if (hls) { hls.destroy(); hls = null; } if (hls) { hls.destroy(); hls = null; }
if (Hls.isSupported()) { if (Hls.isSupported()) {
@@ -179,7 +412,6 @@
}); });
hls.on(Hls.Events.ERROR, (event, data) => { hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) { if (data.fatal) {
console.error("HLS fatal error:", data);
showOffline("Stream error — reloading..."); showOffline("Stream error — reloading...");
setTimeout(attachToStream, 3000); setTimeout(attachToStream, 3000);
} }
@@ -201,18 +433,14 @@
video.style.display = "none"; video.style.display = "none";
} }
// ── Status polling ─────────────────────────────────────────────────────── // ── Status polling ───────────────────────────────────────────────────────
async function fetchStatus() { async function fetchStatus() {
const res = await fetch("/camera/status"); const res = await fetch("/camera/status");
return res.json(); return res.json();
} }
function setStatus(msg) { async function waitForReady(maxAttempts) {
statusEl.textContent = msg; maxAttempts = maxAttempts || 40;
}
async function waitForReady(maxAttempts = 40) {
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
const data = await fetchStatus(); const data = await fetchStatus();
if (data.ready) return true; if (data.ready) return true;
@@ -221,9 +449,10 @@
return false; return false;
} }
// Called on page load and after stream errors — attach if already live
async function attachToStream() { async function attachToStream() {
const data = await fetchStatus(); const data = await fetchStatus();
isRecording = data.recording || false;
if (data.ready) { if (data.ready) {
startHls(); startHls();
renderControls(true); renderControls(true);
@@ -259,15 +488,10 @@
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
} }
// ── Stream start / stop ────────────────────────────────────────────────── // ── Stream start / stop ──────────────────────────────────────────────────
async function startStream() { async function startStream() {
// guard: re-check status in case another client just started it
const current = await fetchStatus(); const current = await fetchStatus();
if (current.running || current.ready) { if (current.running || current.ready) { await attachToStream(); return; }
await attachToStream();
return;
}
const btn = document.getElementById("btn-start"); const btn = document.getElementById("btn-start");
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
@@ -275,7 +499,6 @@
placeholderText.textContent = "Starting..."; placeholderText.textContent = "Starting...";
await fetch("/camera/start", { method: "POST" }); await fetch("/camera/start", { method: "POST" });
setStatus("Waiting for first segment..."); setStatus("Waiting for first segment...");
const ready = await waitForReady(); const ready = await waitForReady();
@@ -301,14 +524,12 @@
startOfflinePoll(); startOfflinePoll();
} }
// ── Init ──────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────
(async () => { (async () => {
await attachToStream(); await attachToStream();
await refreshRecordings();
const data = await fetchStatus(); const data = await fetchStatus();
if (!data.running && !data.ready) { if (!data.running && !data.ready) startOfflinePoll();
startOfflinePoll();
}
})(); })();
</script> </script>
</body> </body>
+119 -5
View File
@@ -4,14 +4,20 @@ from typing import Any
import pytest import pytest
from flask.testing import FlaskClient from flask.testing import FlaskClient
from src.app import app from src.app import app, db
@pytest.fixture @pytest.fixture
def client() -> Generator[FlaskClient, Any, Any]: def client() -> Generator[FlaskClient, Any, Any]:
app.config["TESTING"] = True app.config["TESTING"] = True
with app.test_client() as client: app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
yield client with app.app_context():
db.create_all()
yield app.test_client()
db.drop_all()
# ── Stream tests ─────────────────────────────────────────────────────────────
def test_heartbeat_status_code(client: FlaskClient) -> None: def test_heartbeat_status_code(client: FlaskClient) -> None:
@@ -22,14 +28,20 @@ def test_heartbeat_status_code(client: FlaskClient) -> None:
def test_camera_start(client: FlaskClient) -> None: def test_camera_start(client: FlaskClient) -> None:
response = client.post("/camera/start") response = client.post("/camera/start")
assert response.status_code == 200 assert response.status_code == 200
assert response.get_json()["status"] == "started" assert response.get_json()["status"] in ("started", "already_running")
def test_camera_stop(client: FlaskClient) -> None: def test_camera_stop(client: FlaskClient) -> None:
client.post("/camera/start") client.post("/camera/start")
response = client.post("/camera/stop") response = client.post("/camera/stop")
assert response.status_code == 200 assert response.status_code == 200
assert response.get_json()["status"] == "stopped" assert response.get_json()["status"] in ("stopped", "already_stopped")
def test_double_start_is_idempotent(client: FlaskClient) -> None:
client.post("/camera/start")
res = client.post("/camera/start")
assert res.get_json()["status"] == "already_running"
def test_camera_status(client: FlaskClient) -> None: def test_camera_status(client: FlaskClient) -> None:
@@ -38,6 +50,30 @@ def test_camera_status(client: FlaskClient) -> None:
data = response.get_json() data = response.get_json()
assert "running" in data assert "running" in data
assert "ready" in data assert "ready" in data
assert "updated_at" in data
# recording fields must always be present
assert "recording" in data
assert "recording_started_at" in data
def test_camera_status_includes_recording_false_by_default(client: FlaskClient) -> None:
res = client.get("/camera/status")
data = res.get_json()
assert data["recording"] is False
assert data["recording_started_at"] is None
def test_camera_log(client: FlaskClient) -> None:
client.post("/camera/start")
client.post("/camera/stop")
res = client.get("/camera/log")
assert res.status_code == 200
events = res.get_json()
assert len(events) == 2
assert events[0]["action"] == "stop" # most recent first
assert events[1]["action"] == "start"
assert "ip_address" in events[0]
assert "timestamp" in events[0]
def test_hls_404_when_not_running(client: FlaskClient) -> None: def test_hls_404_when_not_running(client: FlaskClient) -> None:
@@ -49,3 +85,81 @@ def test_index_page(client: FlaskClient) -> None:
response = client.get("/") response = client.get("/")
assert response.status_code == 200 assert response.status_code == 200
assert b"Pi Camera" in response.data assert b"Pi Camera" in response.data
# ── Recording tests ───────────────────────────────────────────────────────────
def test_record_start_returns_200(client: FlaskClient) -> None:
res = client.post("/camera/record/start")
assert res.status_code == 200
data = res.get_json()
assert data["status"] in ("recording", "already_recording")
def test_record_stop_when_not_recording(client: FlaskClient) -> None:
res = client.post("/camera/record/stop")
assert res.status_code == 200
assert res.get_json()["status"] == "not_recording"
def test_record_start_then_stop(client: FlaskClient) -> None:
start = client.post("/camera/record/start")
assert start.get_json()["status"] == "recording"
stop = client.post("/camera/record/stop")
assert stop.status_code == 200
assert stop.get_json()["status"] == "stopped"
def test_double_record_start_is_idempotent(client: FlaskClient) -> None:
client.post("/camera/record/start")
res = client.post("/camera/record/start")
assert res.get_json()["status"] == "already_recording"
# clean up
client.post("/camera/record/stop")
def test_status_reflects_recording_state(client: FlaskClient) -> None:
client.post("/camera/record/start")
status = client.get("/camera/status").get_json()
assert status["recording"] is True
assert status["recording_started_at"] is not None
client.post("/camera/record/stop")
status = client.get("/camera/status").get_json()
assert status["recording"] is False
assert status["recording_started_at"] is None
def test_stream_and_record_simultaneously(client: FlaskClient) -> None:
"""Stream and record can be active at the same time without interference."""
client.post("/camera/start")
rec = client.post("/camera/record/start")
assert rec.get_json()["status"] in ("recording", "already_recording")
status = client.get("/camera/status").get_json()
assert status["running"] is True
assert status["recording"] is True
# stopping stream does not affect recording state report from camera object
client.post("/camera/stop")
# recording was stopped as part of camera.stop() — that is expected behaviour
# (the camera itself cleans up on stop)
def test_list_recordings_empty(client: FlaskClient) -> None:
res = client.get("/camera/recordings")
assert res.status_code == 200
assert res.get_json() == []
def test_download_recording_invalid_filename(client: FlaskClient) -> None:
# filename pattern not matching should 404
res = client.get("/camera/recordings/../../etc/passwd")
assert res.status_code in (404, 400)
def test_download_recording_wrong_prefix(client: FlaskClient) -> None:
res = client.get("/camera/recordings/evil.mp4")
assert res.status_code == 404