mirror of
https://github.com/hanzoskill/nest-devices.git
synced 2026-10-30 21:01:58 +00:00
340 lines
13 KiB
Python
340 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Nest Device Access API client.
|
|
Supports credentials from 1Password or environment variables.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
import requests
|
|
|
|
def get_credentials():
|
|
"""
|
|
Retrieve Nest API credentials from 1Password or environment variables.
|
|
|
|
1Password config (optional):
|
|
- NEST_OP_VAULT: vault name (default: "Alfred")
|
|
- NEST_OP_ITEM: item name (default: "Nest Device Access API")
|
|
- OP_SERVICE_ACCOUNT_TOKEN or OP_TOKEN_*: 1Password token
|
|
|
|
Environment variables (fallback):
|
|
- NEST_PROJECT_ID
|
|
- NEST_CLIENT_ID
|
|
- NEST_CLIENT_SECRET
|
|
- NEST_REFRESH_TOKEN
|
|
"""
|
|
# Try 1Password first
|
|
op_token = None
|
|
for key in os.environ:
|
|
if key.startswith('OP_TOKEN_') or key == 'OP_SERVICE_ACCOUNT_TOKEN':
|
|
op_token = os.environ[key]
|
|
break
|
|
|
|
if op_token:
|
|
vault = os.environ.get('NEST_OP_VAULT', 'Alfred')
|
|
item = os.environ.get('NEST_OP_ITEM', 'Nest Device Access API')
|
|
|
|
# Find op binary
|
|
op_path = None
|
|
for path in [os.path.expanduser('~/.local/bin/op'), '/usr/local/bin/op', 'op']:
|
|
try:
|
|
subprocess.run([path, '--version'], capture_output=True, check=True)
|
|
op_path = path
|
|
break
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
continue
|
|
|
|
if op_path:
|
|
try:
|
|
result = subprocess.run(
|
|
[op_path, 'item', 'get', item, '--vault', vault, '--format', 'json'],
|
|
capture_output=True, text=True,
|
|
env={**os.environ, 'OP_SERVICE_ACCOUNT_TOKEN': op_token}
|
|
)
|
|
if result.returncode == 0:
|
|
item_data = json.loads(result.stdout)
|
|
return {f['label']: f.get('value', '') for f in item_data['fields']}
|
|
except Exception:
|
|
pass # Fall through to env vars
|
|
|
|
# Fall back to environment variables
|
|
required = ['NEST_PROJECT_ID', 'NEST_CLIENT_ID', 'NEST_CLIENT_SECRET', 'NEST_REFRESH_TOKEN']
|
|
missing = [k for k in required if not os.environ.get(k)]
|
|
|
|
if missing:
|
|
raise RuntimeError(
|
|
f"Missing credentials. Set environment variables: {', '.join(missing)}\n"
|
|
"Or configure 1Password with OP_SERVICE_ACCOUNT_TOKEN"
|
|
)
|
|
|
|
return {
|
|
'project_id': os.environ['NEST_PROJECT_ID'],
|
|
'client_id': os.environ['NEST_CLIENT_ID'],
|
|
'client_secret': os.environ['NEST_CLIENT_SECRET'],
|
|
'refresh_token': os.environ['NEST_REFRESH_TOKEN'],
|
|
}
|
|
|
|
def get_access_token(creds):
|
|
"""Get a fresh access token using the refresh token."""
|
|
response = requests.post('https://oauth2.googleapis.com/token', data={
|
|
'client_id': creds['client_id'],
|
|
'client_secret': creds['client_secret'],
|
|
'refresh_token': creds['refresh_token'],
|
|
'grant_type': 'refresh_token'
|
|
})
|
|
response.raise_for_status()
|
|
return response.json()['access_token']
|
|
|
|
class NestClient:
|
|
"""Client for the Nest Smart Device Management API."""
|
|
|
|
BASE_URL = 'https://smartdevicemanagement.googleapis.com/v1'
|
|
|
|
def __init__(self, creds=None):
|
|
"""
|
|
Initialize the client.
|
|
|
|
Args:
|
|
creds: Optional dict with project_id, client_id, client_secret, refresh_token.
|
|
If not provided, credentials are loaded from 1Password or env vars.
|
|
"""
|
|
self.creds = creds or get_credentials()
|
|
self.project_id = self.creds['project_id']
|
|
self.access_token = get_access_token(self.creds)
|
|
self.headers = {'Authorization': f'Bearer {self.access_token}'}
|
|
|
|
def _request(self, method, path, **kwargs):
|
|
url = f"{self.BASE_URL}/enterprises/{self.project_id}{path}"
|
|
response = requests.request(method, url, headers=self.headers, **kwargs)
|
|
response.raise_for_status()
|
|
return response.json() if response.text else {}
|
|
|
|
def list_devices(self):
|
|
"""List all devices."""
|
|
return self._request('GET', '/devices').get('devices', [])
|
|
|
|
def get_device(self, device_id):
|
|
"""Get a specific device by ID."""
|
|
return self._request('GET', f'/devices/{device_id}')
|
|
|
|
def execute_command(self, device_id, command, params=None):
|
|
"""Execute a command on a device."""
|
|
body = {'command': command, 'params': params or {}}
|
|
return self._request('POST', f'/devices/{device_id}:executeCommand', json=body)
|
|
|
|
# ─── Thermostat ────────────────────────────────────────────────────────────
|
|
|
|
def set_thermostat_mode(self, device_id, mode):
|
|
"""Set thermostat mode: HEAT, COOL, HEATCOOL, OFF."""
|
|
return self.execute_command(
|
|
device_id,
|
|
'sdm.devices.commands.ThermostatMode.SetMode',
|
|
{'mode': mode}
|
|
)
|
|
|
|
def set_heat_temperature(self, device_id, temp_celsius):
|
|
"""Set heat setpoint in Celsius."""
|
|
return self.execute_command(
|
|
device_id,
|
|
'sdm.devices.commands.ThermostatTemperatureSetpoint.SetHeat',
|
|
{'heatCelsius': temp_celsius}
|
|
)
|
|
|
|
def set_cool_temperature(self, device_id, temp_celsius):
|
|
"""Set cool setpoint in Celsius."""
|
|
return self.execute_command(
|
|
device_id,
|
|
'sdm.devices.commands.ThermostatTemperatureSetpoint.SetCool',
|
|
{'coolCelsius': temp_celsius}
|
|
)
|
|
|
|
def set_heat_cool_temperature(self, device_id, heat_celsius, cool_celsius):
|
|
"""Set both heat and cool setpoints for HEATCOOL mode."""
|
|
return self.execute_command(
|
|
device_id,
|
|
'sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange',
|
|
{'heatCelsius': heat_celsius, 'coolCelsius': cool_celsius}
|
|
)
|
|
|
|
def set_eco_mode(self, device_id, mode):
|
|
"""Set eco mode: MANUAL_ECO or OFF."""
|
|
return self.execute_command(
|
|
device_id,
|
|
'sdm.devices.commands.ThermostatEco.SetMode',
|
|
{'mode': mode}
|
|
)
|
|
|
|
# ─── Camera ────────────────────────────────────────────────────────────────
|
|
|
|
def generate_stream(self, device_id):
|
|
"""Generate a live stream URL for a camera (RTSP)."""
|
|
return self.execute_command(
|
|
device_id,
|
|
'sdm.devices.commands.CameraLiveStream.GenerateRtspStream'
|
|
)
|
|
|
|
def generate_webrtc_stream(self, device_id, offer_sdp):
|
|
"""Generate a WebRTC stream for a camera."""
|
|
return self.execute_command(
|
|
device_id,
|
|
'sdm.devices.commands.CameraLiveStream.GenerateWebRtcStream',
|
|
{'offerSdp': offer_sdp}
|
|
)
|
|
|
|
def extend_stream(self, device_id, stream_token):
|
|
"""Extend an active RTSP stream."""
|
|
return self.execute_command(
|
|
device_id,
|
|
'sdm.devices.commands.CameraLiveStream.ExtendRtspStream',
|
|
{'streamExtensionToken': stream_token}
|
|
)
|
|
|
|
def stop_stream(self, device_id, stream_token):
|
|
"""Stop an active stream."""
|
|
return self.execute_command(
|
|
device_id,
|
|
'sdm.devices.commands.CameraLiveStream.StopRtspStream',
|
|
{'streamExtensionToken': stream_token}
|
|
)
|
|
|
|
|
|
def format_device(device):
|
|
"""Format device info for display."""
|
|
name = device['name'].split('/')[-1]
|
|
device_type = device['type'].split('.')[-1]
|
|
traits = device.get('traits', {})
|
|
|
|
info = {'id': name, 'type': device_type}
|
|
|
|
if 'sdm.devices.traits.Info' in traits:
|
|
info['custom_name'] = traits['sdm.devices.traits.Info'].get('customName', '')
|
|
|
|
if 'sdm.devices.traits.Temperature' in traits:
|
|
temp = traits['sdm.devices.traits.Temperature'].get('ambientTemperatureCelsius')
|
|
if temp:
|
|
info['temperature_c'] = round(temp, 1)
|
|
info['temperature_f'] = round(temp * 9/5 + 32, 1)
|
|
|
|
if 'sdm.devices.traits.Humidity' in traits:
|
|
info['humidity'] = traits['sdm.devices.traits.Humidity'].get('ambientHumidityPercent')
|
|
|
|
if 'sdm.devices.traits.ThermostatMode' in traits:
|
|
info['mode'] = traits['sdm.devices.traits.ThermostatMode'].get('mode')
|
|
info['available_modes'] = traits['sdm.devices.traits.ThermostatMode'].get('availableModes', [])
|
|
|
|
if 'sdm.devices.traits.ThermostatTemperatureSetpoint' in traits:
|
|
setpoint = traits['sdm.devices.traits.ThermostatTemperatureSetpoint']
|
|
if 'heatCelsius' in setpoint:
|
|
info['heat_setpoint_c'] = round(setpoint['heatCelsius'], 1)
|
|
if 'coolCelsius' in setpoint:
|
|
info['cool_setpoint_c'] = round(setpoint['coolCelsius'], 1)
|
|
|
|
if 'sdm.devices.traits.ThermostatHvac' in traits:
|
|
info['hvac_status'] = traits['sdm.devices.traits.ThermostatHvac'].get('status')
|
|
|
|
if 'sdm.devices.traits.ThermostatEco' in traits:
|
|
eco = traits['sdm.devices.traits.ThermostatEco']
|
|
info['eco_mode'] = eco.get('mode')
|
|
if 'heatCelsius' in eco:
|
|
info['eco_heat_c'] = eco['heatCelsius']
|
|
if 'coolCelsius' in eco:
|
|
info['eco_cool_c'] = eco['coolCelsius']
|
|
|
|
if 'sdm.devices.traits.CameraLiveStream' in traits:
|
|
info['live_stream'] = True
|
|
info['stream_protocols'] = traits['sdm.devices.traits.CameraLiveStream'].get('supportedProtocols', [])
|
|
|
|
if 'sdm.devices.traits.CameraEventImage' in traits:
|
|
info['event_image'] = True
|
|
|
|
return info
|
|
|
|
|
|
def fahrenheit_to_celsius(f):
|
|
return (f - 32) * 5/9
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Nest Device Access API client')
|
|
subparsers = parser.add_subparsers(dest='command', help='Commands')
|
|
|
|
# List devices
|
|
subparsers.add_parser('list', help='List all devices')
|
|
|
|
# Get device
|
|
get_parser = subparsers.add_parser('get', help='Get device details')
|
|
get_parser.add_argument('device_id', help='Device ID')
|
|
|
|
# Set temperature
|
|
temp_parser = subparsers.add_parser('set-temp', help='Set thermostat temperature')
|
|
temp_parser.add_argument('device_id', help='Device ID')
|
|
temp_parser.add_argument('temperature', type=float, help='Temperature')
|
|
temp_parser.add_argument('--unit', choices=['c', 'f'], default='c', help='Unit (c/f)')
|
|
temp_parser.add_argument('--type', choices=['heat', 'cool'], default='heat', help='Setpoint type')
|
|
|
|
# Set mode
|
|
mode_parser = subparsers.add_parser('set-mode', help='Set thermostat mode')
|
|
mode_parser.add_argument('device_id', help='Device ID')
|
|
mode_parser.add_argument('mode', choices=['HEAT', 'COOL', 'HEATCOOL', 'OFF'], help='Mode')
|
|
|
|
# Set eco
|
|
eco_parser = subparsers.add_parser('set-eco', help='Set eco mode')
|
|
eco_parser.add_argument('device_id', help='Device ID')
|
|
eco_parser.add_argument('mode', choices=['MANUAL_ECO', 'OFF'], help='Eco mode')
|
|
|
|
# Camera stream
|
|
stream_parser = subparsers.add_parser('stream', help='Generate camera stream URL')
|
|
stream_parser.add_argument('device_id', help='Device ID')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.command:
|
|
parser.print_help()
|
|
return
|
|
|
|
try:
|
|
client = NestClient()
|
|
|
|
if args.command == 'list':
|
|
devices = client.list_devices()
|
|
print(json.dumps([format_device(d) for d in devices], indent=2))
|
|
|
|
elif args.command == 'get':
|
|
device = client.get_device(args.device_id)
|
|
print(json.dumps(format_device(device), indent=2))
|
|
|
|
elif args.command == 'set-temp':
|
|
temp = args.temperature
|
|
if args.unit == 'f':
|
|
temp = fahrenheit_to_celsius(temp)
|
|
|
|
if args.type == 'heat':
|
|
client.set_heat_temperature(args.device_id, temp)
|
|
else:
|
|
client.set_cool_temperature(args.device_id, temp)
|
|
print(json.dumps({'success': True, 'setpoint_c': round(temp, 1)}))
|
|
|
|
elif args.command == 'set-mode':
|
|
client.set_thermostat_mode(args.device_id, args.mode)
|
|
print(json.dumps({'success': True, 'mode': args.mode}))
|
|
|
|
elif args.command == 'set-eco':
|
|
client.set_eco_mode(args.device_id, args.mode)
|
|
print(json.dumps({'success': True, 'eco_mode': args.mode}))
|
|
|
|
elif args.command == 'stream':
|
|
result = client.generate_stream(args.device_id)
|
|
print(json.dumps(result, indent=2))
|
|
|
|
except Exception as e:
|
|
print(json.dumps({'error': str(e)}), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|