fix speaker volume control and auth
- Rename USERNAME/PASSWORD to UNIFI_USERNAME/UNIFI_PASSWORD to avoid conflict with the Windows USERNAME system environment variable - Set both speakerSettings.volume and speakerSettings.speakerVolume in the PATCH request - Log volume before and after the change to verify it took effect
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
HOST=192.168.1.1
|
||||
API_KEY=
|
||||
UNIFI_USERNAME=
|
||||
UNIFI_PASSWORD=
|
||||
|
||||
14
README.md
14
README.md
@@ -17,18 +17,20 @@ cp .env.example .env
|
||||
```
|
||||
|
||||
```env
|
||||
HOST=192.168.1.1 # UniFi controller IP
|
||||
API_KEY= # API key from UniFi OS profile → API Tokens
|
||||
HOST=192.168.1.1 # UniFi controller IP
|
||||
API_KEY= # API key from UniFi OS profile → API Tokens
|
||||
UNIFI_USERNAME= # Local UniFi OS username (for speaker volume control)
|
||||
UNIFI_PASSWORD= # Local UniFi OS password
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
[UniFi Protect API v6.2.88](https://developer.ui.com/protect/v6.2.88)
|
||||
|
||||
> **Note:** For use cases requiring real-time event subscriptions (WebSocket),
|
||||
speaker volume control, or access to settings not exposed by the official API,
|
||||
consider [uiprotect](https://github.com/uilibs/uiprotect) — an unofficial Python
|
||||
library wrapping the private UniFi Protect API.
|
||||
> **Note:** For use cases requiring real-time event subscriptions (WebSocket) or
|
||||
access to settings not exposed by the official API, consider
|
||||
[uiprotect](https://github.com/uilibs/uiprotect) — an unofficial Python library
|
||||
wrapping the private UniFi Protect API.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
29
dump_camera.py
Normal file
29
dump_camera.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE = f"https://{os.environ['HOST']}/proxy/protect/integration/v1"
|
||||
HEADERS = {"X-API-Key": os.environ["API_KEY"]}
|
||||
CAMERA_NAME = "G6 Pro 360"
|
||||
|
||||
|
||||
def main():
|
||||
with httpx.Client(headers=HEADERS, verify=False) as client:
|
||||
cameras = client.get(f"{BASE}/cameras").raise_for_status().json()
|
||||
camera = next(c for c in cameras if CAMERA_NAME in c["name"])
|
||||
|
||||
print("=== Camera ===")
|
||||
data = client.get(f"{BASE}/cameras/{camera['id']}").raise_for_status().json()
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
print("\n=== Sensors ===")
|
||||
sensors = client.get(f"{BASE}/sensors").raise_for_status().json()
|
||||
print(json.dumps(sensors, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
43
main.py
43
main.py
@@ -6,27 +6,60 @@ from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
API_KEY = os.environ["API_KEY"]
|
||||
HOST = os.environ["HOST"]
|
||||
API_KEY = os.environ["API_KEY"]
|
||||
USERNAME = os.environ["UNIFI_USERNAME"]
|
||||
PASSWORD = os.environ["UNIFI_PASSWORD"]
|
||||
|
||||
BASE = f"https://{HOST}/proxy/protect/integration/v1"
|
||||
HEADERS = {"X-API-Key": API_KEY}
|
||||
PRIVATE_BASE = f"https://{HOST}/proxy/protect/api"
|
||||
|
||||
CAMERA_NAME = "G6 Pro 360"
|
||||
AUDIO_FILE = "./data/hello.wav"
|
||||
VOLUME = 1.0 # 10%
|
||||
SPEAKER_VOLUME = 50 # 0-100
|
||||
|
||||
|
||||
def set_speaker_volume(camera_id: str, volume: int):
|
||||
# Accept-Encoding must be suppressed at request level — server returns 403 when present
|
||||
with httpx.Client(verify=False, http2=True) as client:
|
||||
res = client.post(
|
||||
f"https://{HOST}/api/auth/login",
|
||||
content=f'{{"username":"{USERNAME}","password":"{PASSWORD}","rememberMe":false}}'.encode(),
|
||||
headers={"Content-Type": "application/json", "Accept-Encoding": ""},
|
||||
)
|
||||
res.raise_for_status()
|
||||
csrf = res.headers.get("x-csrf-token")
|
||||
|
||||
def get_volumes():
|
||||
s = client.get(f"{PRIVATE_BASE}/cameras/{camera_id}").raise_for_status().json()["speakerSettings"]
|
||||
return s["volume"], s["speakerVolume"]
|
||||
|
||||
before = get_volumes()
|
||||
print(f"Speaker volume before: volume={before[0]} speakerVolume={before[1]}")
|
||||
|
||||
client.patch(
|
||||
f"{PRIVATE_BASE}/cameras/{camera_id}",
|
||||
headers={"x-csrf-token": csrf},
|
||||
json={"speakerSettings": {"volume": volume, "speakerVolume": volume}},
|
||||
).raise_for_status()
|
||||
|
||||
after = get_volumes()
|
||||
print(f"Speaker volume after: volume={after[0]} speakerVolume={after[1]}")
|
||||
|
||||
|
||||
def main():
|
||||
with httpx.Client(headers=HEADERS, verify=False) as client:
|
||||
with httpx.Client(headers={"X-API-Key": API_KEY}, verify=False) as client:
|
||||
cameras = client.get(f"{BASE}/cameras").raise_for_status().json()
|
||||
camera = next(c for c in cameras if CAMERA_NAME in c["name"])
|
||||
print(f"Camera: {camera['name']} ({camera['id']})")
|
||||
|
||||
set_speaker_volume(camera["id"], SPEAKER_VOLUME)
|
||||
|
||||
session = client.post(f"{BASE}/cameras/{camera['id']}/talkback-session").raise_for_status().json()
|
||||
print(f"Talkback: {session['url']} ({session['codec']} {session['samplingRate']}Hz)")
|
||||
|
||||
proc = subprocess.Popen([
|
||||
"ffmpeg", "-re", "-i", AUDIO_FILE,
|
||||
"-af", f"volume={VOLUME}",
|
||||
"-acodec", "libopus",
|
||||
"-ar", str(session["samplingRate"]),
|
||||
"-ac", "1",
|
||||
|
||||
@@ -5,6 +5,6 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"httpx>=0.28.1",
|
||||
"httpx[http2]>=0.28.1",
|
||||
"python-dotenv>=1.2.1",
|
||||
]
|
||||
|
||||
40
uv.lock
generated
40
uv.lock
generated
@@ -28,13 +28,13 @@ name = "g6pro360"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "httpx", extras = ["http2"], specifier = ">=0.28.1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
]
|
||||
|
||||
@@ -47,6 +47,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "hpack" },
|
||||
{ name = "hyperframe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hpack"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
@@ -75,6 +97,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
http2 = [
|
||||
{ name = "h2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyperframe"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
|
||||
Reference in New Issue
Block a user