diff --git a/.vscode/launch.json b/.vscode/launch.json index 79b83e3..f5b32f3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -43,7 +43,7 @@ "name": "Python Debugger: PROJmon", "type": "debugpy", "request": "launch", - "program": "${file}", + "program": "${workspaceFolder}/projmon/projmon.py", "console": "integratedTerminal", "env": { "INTERVAL" : "5", diff --git a/projmon/commands.py b/projmon/commands.py index 0f06ac4..6bf360f 100644 --- a/projmon/commands.py +++ b/projmon/commands.py @@ -1,128 +1,136 @@ -from projrequest import ProjectorConnection from datetime import datetime from typing import Dict, List, Any, ClassVar, Optional from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, model_validator, field_validator -class BaseCommand(BaseModel): +class MessageHeaderWrapper(BaseModel): + Id: int + Type: str + Timestamp: datetime + Source: str + + @field_validator('Timestamp', mode='before') + @classmethod + def to_datetime(cls, value: str) -> datetime: + return datetime.fromtimestamp(int(value)/1000) + +class SMSMessage(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - timestamp: datetime = datetime.now() - type: str = '' - projector: ProjectorConnection = Field(exclude=True) path: ClassVar[List[str]] params: ClassVar[Dict[str, Any] | None] = None - def update(self) -> List | Dict | None: - resp = self.projector.get(path=self.path, params=self.params) - if resp is not None: - self.timestamp = datetime.fromtimestamp(float(resp['timestamp'])/1000) - self.type = resp['type'] - return resp['body'] - return None + MessageHeader: MessageHeaderWrapper - def dump(self): - return self.model_dump_json(indent=2) - -class DCPInfo(BaseModel): - ID: Optional[UUID] = None - Title: Optional[str] = None - Path: Optional[str] = None - Size: Optional[int] = None - ImportTime: Optional[datetime] = None - IsImported: Optional[bool] = None - VerifyStatus: Optional[bool] = None - ValidateStatus: Optional[bool] = None - IsPlayable: Optional[bool] = None - IsTransferred: Optional[bool] = None +class DCPInfoWrapper(BaseModel): + ID: UUID + Title: str + Path: str + Size: int + ImportTime: datetime + IsImported: bool + VerifyStatus: str + ValidateStatus: str + IsPlayable: bool + IsTransferred: bool -class DCPInfoList(BaseCommand): +class DCPInfoList(SMSMessage): + DCPInfo: List[DCPInfoWrapper] | None + path = ['content', 'dcp', 'info', 'list'] params = { 'formatDate': 'false' } - dcpInfoList: Optional[List[DCPInfo]] = None - def get(self): - rv = self.update() - if rv: - self.dcpInfoList = [DCPInfo(**e) for e in rv] +class PowerStatusWrapper(BaseModel): + Device: str + State: str -class PowerStatus(BaseModel): - Device: Optional[str] = None - State: Optional[str] = None - -class PowerStatusList(BaseCommand): - powerStatusList: Optional[List[PowerStatus]] = None - path = ['status', 'sms', 'powerstatus'] - - def get(self): - rv = self.update() - if isinstance(rv, dict): - self.powerStatusList = [PowerStatus(**e) for e in rv['PowerStatus']] +class PowerStatus(SMSMessage): + PowerStatus: List[PowerStatusWrapper] -class ShowStatusDetailClass(BaseModel): - Type: Optional[str] = None - Id: Optional[UUID] = None - RemainingTime: Optional[int] = None - ElapsedTime: Optional[int] = None - TotalDuration: Optional[int] = None - CurrentEventId: Optional[UUID] = None - CurrentEventType: Optional[str] = None - IsStoppedByMalfunction: Optional[bool] = None - RewindTimeList: Optional[str] = None - MalfunctionTime: Optional[int] = None + path = ['status', 'sms', 'powerstatus'] + +class ShowStatusDetailWrapper(BaseModel): + Type: str + Id: UUID + RemainingTime: int + ElapsedTime: int + TotalDuration: int + CurrentEventId: UUID + CurrentEventType: str + IsStoppedByMalfunction: bool + RewindTimeList: List[int] + MalfunctionTime: int + + @field_validator('RewindTimeList', mode='before') + @classmethod + def extract(cls, data: str) -> List[int]: + return [int(v) for v in data.split(',')] + +class ShowStatus(SMSMessage): + PlayState: str + ShowStatusDetail: ShowStatusDetailWrapper + PlayBackMode: str + AtmosPlayingStatus: str -class ShowStatus(BaseCommand): - PlayState: Optional[str] = None - ShowStatusDetail: Optional[ShowStatusDetailClass] = None - PlayBackMode: Optional[str] = None - AtmosPlayingStatus: Optional[str] = None path = ['playback', 'showstatus'] - def get(self): - rv = self.update() - if isinstance(rv, dict): - self.PlayState = rv['PlayState'] - self.ShowStatusDetail = ShowStatusDetailClass(**rv['ShowStatusDetail']) - self.PlayBackMode = rv['PlayBackMode'] - self.AtmosPlayingStatus = rv['AtmosPlayingStatus'] - -class ImportProgressClass(BaseModel): - TotalBytesToTransfer: Optional[int] = None - BytesTransferred: Optional[int] = None - PercentCompleted: Optional[int] = None - InProgress: Optional[int] = None - ImportPath: Optional[str] = None - CompletionStatus: Optional[str] = None - CompletionTime: Optional[str] = None +class ImportProgressWrapper(BaseModel): + TotalBytesToTransfer: int + BytesTransferred: int + PercentCompleted: int + InProgress: bool + ImportPath: str + CompletionStatus: str + CompletionTime: Optional[datetime] = None DCPTitle: Optional[str] = None -class ValidationProgressClass(BaseModel): - TotalBytesToValidate: Optional[int] = None - BytesValidated: Optional[int] = None - PercentCompleted: Optional[int] = None - InProgress: Optional[bool] = None - Id: Optional[UUID] = None - CompletionStatus: Optional[str] = None - CompletionTime: Optional[datetime] = None +class ValidationProgress(BaseModel): + TotalBytesToValidate: int + BytesValidated: int + PercentCompleted: int + InProgress: bool + Id: UUID + CompletionStatus: str + CompletionTime: datetime + +type ValidationProgressElem = ValidationProgress +class ValidationProgressWrapper(BaseModel): + ValidationProgress: ValidationProgressElem class JobProgress(BaseModel): - Id: Optional[int] = None - ValidateAfterImport: Optional[bool] = None - AggregatePercentValidated: Optional[int] = None - State: Optional[str] = None - ImportProgress: Optional[ImportProgressClass] = None - ValidationProgressList: Optional[List[ValidationProgressClass]] = None - IngestedByFolder: Optional[bool] = None - ContentsTransferType: Optional[str] = None + Id: int + ValidateAfterImport: bool + AggregatePercentValidated: int + State: str + ImportProgress: ImportProgressWrapper + ValidationProgressList: ValidationProgressWrapper | None + IngestedByFolder: bool + ContentsTransferType: str + +type JobProgressElem = JobProgress +class JobProgressWrapper(BaseModel): + JobProgress: List[JobProgressElem] | JobProgressElem + +class DCPImportJobList(SMSMessage): + IsPaused: bool + JobProgressList: JobProgressWrapper | None -class DCPImportJobList(BaseCommand): - IsPaused: Optional[bool] = None - JobProgressList: Optional[List[JobProgress]] = None path = ['content', 'dcp', 'command'] params = {'action': 'ListImportJobs'} - def get(self): - rv = self.update() - if isinstance(rv, dict): - self.IsPaused = rv['IsPaused'] - self.JobProgressList = [JobProgress(**e) for e in rv['JobProgressList']] +class StorageInfo(SMSMessage): + FileSystem: str + TotalCapacity: int + SpaceInUse: int + FreeSpace: int + + path = ['status', 'storage', 'info'] + params = {'area': 'DCP'} + + @model_validator(mode='before') + @classmethod + def extract(cls, data: Any) -> Any: + if isinstance(data, dict): + data.update(**data['MBStorageUsage']) + del data['MBStorageUsage'] + return data diff --git a/projmon/haconfig.py b/projmon/haconfig.py new file mode 100644 index 0000000..835dbea --- /dev/null +++ b/projmon/haconfig.py @@ -0,0 +1,14 @@ + +HA_CONFIG_TEMPLATE = { + "name": "{device_friendly_name}", + "state_topic": "homeassistant/sensor/{device_name}/{device_state}/state", + "unique_id": "{device_name}_status", + "icon": "{device_icon}", + "device": { + "identifiers": ["{device_name}"], + "name": "{device_friendly_name}", + "manufacturer": "Edelweiss_Automation", + "model": "Edelweiss_Automation" + } +} + diff --git a/projmon/projmon.py b/projmon/projmon.py index e16b00c..a2d7fef 100644 --- a/projmon/projmon.py +++ b/projmon/projmon.py @@ -5,7 +5,7 @@ import logging import json import projrequest as projrequest -from commands import ShowStatus, PowerStatusList +from commands import ShowStatus, PowerStatus, DCPImportJobList, DCPInfoList, StorageInfo from utils import * from influxdb_client_3 import InfluxDBClient3, Point @@ -39,21 +39,17 @@ def main() -> int: username=env['PROJECTOR_USER'], password=env['PROJECTOR_PASSWORD'] ) - ps = PowerStatusList(projector=projector) - ss = ShowStatus(projector=projector) + while handler.running: try: now:float = time.time() - - ps.get() - ss.get() - print(ps.dump()) - print(ss.dump()) - + ret = projector.request(ShowStatus) + print(ret) last: float = time.time() cycle_time: float = last - now LOGGER.debug(f"Cycle Time: {cycle_time:4.3f}") - time.sleep(INTERVAL-cycle_time) + if cycle_time < INTERVAL: + time.sleep(INTERVAL-cycle_time) except Exception as e: print(f"Unexpected exception: [{e}]") return 1 diff --git a/projmon/projrequest.py b/projmon/projrequest.py index 742e2f8..e01144b 100644 --- a/projmon/projrequest.py +++ b/projmon/projrequest.py @@ -4,9 +4,15 @@ import urllib3 urllib3.disable_warnings(category=urllib3.exceptions.InsecureRequestWarning) import xmltodict from requests.auth import HTTPBasicAuth -from datetime import datetime +from os import getcwd +import json -from typing import Dict, List, Any +from commands import SMSMessage + +from typing import Dict, List, Any, Type, TypeVar +T = TypeVar("T", bound=SMSMessage) + +TEST = True class ProjectorConnection(): ip: str @@ -33,13 +39,24 @@ class ProjectorConnection(): timeout=4, verify=False ) + response.raise_for_status() if response.status_code == 200: # HTTP ok response - rv: dict = {} - content: dict = xmltodict.parse(response.text)["SMSMessage"] # Common containert for all messages - header: dict = content["MessageHeader"] - body: dict = content["MessageBody"] - rv['type'] = header.get("Type", None) - rv['timestamp'] = int(header.get("Timestamp", datetime.now())) - rv['body'] = body[rv["type"]] - return rv + return xmltodict.parse(response.text.replace("%2F", "/").replace("%3A", ":")) # Common containert for all messages + return None + + def request(self, cls: Type[T]) -> T | None: + rv: dict | None + if TEST: + with open(f'{getcwd()}/projmon/xmlresponses/showstatus.xml', 'r') as fd: + rv = xmltodict.parse(fd.read().replace("%2F", "/").replace("%3A", ":")) + else: + rv = self.get(cls.path, cls.params) + if isinstance(rv, dict): + rv = rv['SMSMessage'] + type: str = rv['MessageHeader']['Type'] + rv.update(**rv['MessageBody'][type]) + del rv['MessageBody'] + del rv['@xmlns'] + print(json.dumps(rv, indent = 2)) + return cls.model_validate(rv) return None diff --git a/projmon/xmlresponses/importjob_empty.xml b/projmon/xmlresponses/importjob_empty.xml new file mode 100644 index 0000000..b1239e6 --- /dev/null +++ b/projmon/xmlresponses/importjob_empty.xml @@ -0,0 +1,16 @@ + + + + -1 + DCPImportJobList + 1771320721827 + LSM-100v2 + + + + false + + + + + diff --git a/projmon/xmlresponses/importjob_pause.xml b/projmon/xmlresponses/importjob_pause.xml new file mode 100644 index 0000000..14c7068 --- /dev/null +++ b/projmon/xmlresponses/importjob_pause.xml @@ -0,0 +1,36 @@ + + + + -1 + DCPImportJobList + 1771321131757 + LSM-100v2 + + + + true + + + + 0 + true + 0 + PAUSED + + 197696878067 + 4904562323 + 2 + false + ftp%3A%2F192.168.31.16%2FGRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV + CANCELLED + 2026-02-17T10:38:40.210+01:00 + GRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV + + + true + FTPINGEST + + + + + diff --git a/projmon/xmlresponses/importjob_run.xml b/projmon/xmlresponses/importjob_run.xml new file mode 100644 index 0000000..2a2bd09 --- /dev/null +++ b/projmon/xmlresponses/importjob_run.xml @@ -0,0 +1,38 @@ + + + + -1 + DCPImportJobList + 1771321082485 + LSM-100v2 + + + + false + + 0 + + + + 0 + true + 0 + IMPORTING + + 197696878067 + 998483110 + 1 + true + ftp%3A%2F192.168.31.16%2FGRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV + NONE + GRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV + + + true + FTPINGEST + + + + + +