#!/usr/bin/env python3
"""Read-only on-site collector, no inbound listener and no write to heating APIs.
Config is local 0600 JSON. Uplink HTTPS only, TLS verified, credentials never logged.
"""
import argparse,json,os,secrets,stat,time,urllib.request,urllib.parse,urllib.error
from datetime import datetime,timezone
from pathlib import Path

class NoRedirect(urllib.request.HTTPRedirectHandler):
 def redirect_request(self,*args,**kwargs):return None

def request_json(url,token=None,payload=None):
 headers={'Accept':'application/json'}
 if token:headers['Authorization']='Bearer '+token
 data=None
 if payload is not None:data=json.dumps(payload,allow_nan=False).encode();headers['Content-Type']='application/json'
 req=urllib.request.Request(url,data=data,headers=headers,method='POST' if payload is not None else 'GET')
 with urllib.request.build_opener(NoRedirect()).open(req,timeout=20) as response:raw=response.read(500001)
 if len(raw)>500000:raise ValueError('Réponse trop volumineuse')
 return json.loads(raw)

def flatten(data,prefix='',limit=100):
 points=[]
 def walk(value,path,depth):
  if depth>7 or len(points)>=limit:return
  if any(x in path.lower() for x in ('password','token','secret','email','address','serial','uuid')):return
  if isinstance(value,dict):
   for k,v in value.items():walk(v,(path+'.' if path else '')+str(k),depth+1)
  elif isinstance(value,list):
   for i,v in enumerate(value[:10]):walk(v,path+'.'+str(i),depth+1)
  elif value is None or isinstance(value,(str,bool,int,float)):
   if isinstance(value,str) and len(value)>200:return
   points.append({'name':path[:120],'value':value,'unit':'','observed_at':None})
 walk(data,prefix,0);return points

def validate(config):
 if config.get('provider') not in ('emsesp','ebusd','homeassistant','modbus','bacnet','mqtt'):raise ValueError('Type de passerelle inconnu')
 source=urllib.parse.urlsplit(config.get('source_url',''))
 if config['provider'] not in ('modbus','bacnet','mqtt') and (source.scheme not in ('http','https') or not source.hostname or source.username or source.password or source.query or source.fragment or source.path not in ('','/')):raise ValueError('Adresse locale invalide : utiliser http(s)://hôte:port sans chemin ni identifiant')
 uplink=urllib.parse.urlsplit(config.get('smarto_url',''))
 if uplink.scheme!='https' or not uplink.hostname or uplink.username or uplink.password or uplink.query or uplink.fragment or uplink.path not in ('','/'):raise ValueError('Adresse Smarto invalide : HTTPS obligatoire, sans chemin')
 if not isinstance(config.get('device_token'),str) or len(config['device_token'])<20:raise ValueError('Jeton Smarto équipement requis')
 if not 60<=config.get('interval_seconds',300)<=3600:raise ValueError('Intervalle compris entre 60 et 3600 secondes')
 if config['provider']=='homeassistant':
  entities=config.get('entities',[])
  if not entities or len(entities)>40 or not all(isinstance(e,str) and e.count('.')==1 and all(c.isalnum() or c in '._' for c in e) for e in entities):raise ValueError('Choisir entre 1 et 40 identifiants d’entités valides')
  if not config.get('source_token'):raise ValueError('Jeton Home Assistant requis')
 if config['provider'] in ('modbus','bacnet','mqtt'):
  import protocol_collectors
  protocol_collectors.validate(config)
 return config

def collect(config):
 if config['provider'] in ('modbus','bacnet','mqtt'):
  import protocol_collectors
  return protocol_collectors.collect(config)
 base=config['source_url'].rstrip('/');provider=config['provider'];token=config.get('source_token');points=[]
 if provider=='emsesp':
  # Endpoint fixed to EMS-ESP's information GET, never /commands or a setter.
  for device in config.get('ems_devices',['boiler','thermostat']):
   if device not in ('boiler','thermostat','solar','mixer','heatpump'):raise ValueError('Catégorie EMS non prise en charge')
   raw=request_json(base+'/api/'+device,token)
   if not isinstance(raw,dict) or not raw:raise ValueError('Réponse EMS absente ou invalide')
   points.extend(flatten(raw,device,100-len(points)))
 elif provider=='ebusd':
  raw=request_json(base+'/data?verbose=true',token)
  if not isinstance(raw,dict) or raw.get('global',{}).get('signal') is not True:raise ValueError('Signal eBUS absent ou réponse incompatible')
  for circuit,data in raw.items():
   if circuit=='global' or not isinstance(data,dict):continue
   for name,message in data.get('messages',{}).items():
    if message.get('write'):continue
    timestamp=message.get('lastup');observed=datetime.fromtimestamp(timestamp,timezone.utc).isoformat() if isinstance(timestamp,(int,float)) and timestamp>0 else None
    for field,value in message.get('fields',{}).items():
     if not isinstance(value,dict):continue
     point={'name':(circuit+'.'+name+'.'+field)[:120],'value':value.get('value'),'unit':str(value.get('unit',''))[:30],'observed_at':observed}
     if point['value'] is None or isinstance(point['value'],(str,bool,int,float)):points.append(point)
     if len(points)>=100:return points
 elif provider=='homeassistant':
  for entity in config['entities']:
   raw=request_json(base+'/api/states/'+urllib.parse.quote(entity,safe='._'),token)
   if raw.get('entity_id')!=entity:raise ValueError('Entité Home Assistant inattendue')
   attrs=raw.get('attributes') or {};observed=raw.get('last_updated')
   points.append({'name':entity,'value':str(raw.get('state','unknown'))[:200],'unit':str(attrs.get('unit_of_measurement',''))[:30],'observed_at':observed})
   # Thermostats expose actual/target temperature as attributes, not in state.
   for key in ('current_temperature','temperature','current_humidity','hvac_action'):
    value=attrs.get(key)
    if value is not None and isinstance(value,(str,bool,int,float)) and len(points)<100:points.append({'name':entity+'.'+key,'value':value,'unit':'°C' if 'temperature' in key else '%' if key=='current_humidity' else '', 'observed_at':observed})
 if not points:raise ValueError('Aucun point de données disponible')
 return points[:100]

def main():
 p=argparse.ArgumentParser();p.add_argument('--config',required=True,type=Path);p.add_argument('--once',action='store_true');args=p.parse_args()
 if os.name=='posix' and args.config.stat().st_mode & (stat.S_IRWXG|stat.S_IRWXO):raise SystemExit('Le fichier de configuration doit être privé : chmod 600 chemin-du-fichier')
 config=validate(json.loads(args.config.read_text()));boot=secrets.token_hex(16);seq=0
 while True:
  failed=False
  try:
   points=collect(config)
   packet={'provider':config['provider'],'boot_id':boot,'seq':seq,'collected_at':datetime.now(timezone.utc).isoformat(),'points':points}
   while len(json.dumps(packet,allow_nan=False).encode())>30000 and packet['points']:packet['points'].pop()
   if not packet['points']:raise ValueError('Paquet trop volumineux')
   result=request_json(config['smarto_url'].rstrip('/')+'/api/vendor-telemetry',config['device_token'],packet)
   if result.get('accepted'):print('SMARTO_AGENT_OK : collecte transmise.',flush=True)
   else:print('Collecte non acceptée ; vérifier la configuration Smarto et l’horloge.',flush=True);failed=True
  except Exception:
   # No exception string, URL, token or response body is logged.
   print('Collecte échouée : vérifier passerelle, authentification, horloge et connecteur Smarto.',flush=True);failed=True
  seq+=1
  if args.once:return 1 if failed else 0
  time.sleep(config.get('interval_seconds',300))

if __name__=='__main__':raise SystemExit(main())
