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
+
+
+
+
+
+