Refactored pydantic implementation, cleaner with nested validation
This commit is contained in:
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -43,7 +43,7 @@
|
|||||||
"name": "Python Debugger: PROJmon",
|
"name": "Python Debugger: PROJmon",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${file}",
|
"program": "${workspaceFolder}/projmon/projmon.py",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"env": {
|
"env": {
|
||||||
"INTERVAL" : "5",
|
"INTERVAL" : "5",
|
||||||
|
|||||||
@@ -1,128 +1,136 @@
|
|||||||
from projrequest import ProjectorConnection
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from typing import Dict, List, Any, ClassVar, Optional
|
from typing import Dict, List, Any, ClassVar, Optional
|
||||||
from uuid import UUID
|
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)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
timestamp: datetime = datetime.now()
|
|
||||||
type: str = ''
|
|
||||||
projector: ProjectorConnection = Field(exclude=True)
|
|
||||||
path: ClassVar[List[str]]
|
path: ClassVar[List[str]]
|
||||||
params: ClassVar[Dict[str, Any] | None] = None
|
params: ClassVar[Dict[str, Any] | None] = None
|
||||||
|
|
||||||
def update(self) -> List | Dict | None:
|
MessageHeader: MessageHeaderWrapper
|
||||||
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
|
|
||||||
|
|
||||||
def dump(self):
|
class DCPInfoWrapper(BaseModel):
|
||||||
return self.model_dump_json(indent=2)
|
ID: UUID
|
||||||
|
Title: str
|
||||||
|
Path: str
|
||||||
|
Size: int
|
||||||
|
ImportTime: datetime
|
||||||
|
IsImported: bool
|
||||||
|
VerifyStatus: str
|
||||||
|
ValidateStatus: str
|
||||||
|
IsPlayable: bool
|
||||||
|
IsTransferred: bool
|
||||||
|
|
||||||
class DCPInfo(BaseModel):
|
class DCPInfoList(SMSMessage):
|
||||||
ID: Optional[UUID] = None
|
DCPInfo: List[DCPInfoWrapper] | 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 DCPInfoList(BaseCommand):
|
|
||||||
path = ['content', 'dcp', 'info', 'list']
|
path = ['content', 'dcp', 'info', 'list']
|
||||||
params = { 'formatDate': 'false' }
|
params = { 'formatDate': 'false' }
|
||||||
dcpInfoList: Optional[List[DCPInfo]] = None
|
|
||||||
|
|
||||||
def get(self):
|
class PowerStatusWrapper(BaseModel):
|
||||||
rv = self.update()
|
Device: str
|
||||||
if rv:
|
State: str
|
||||||
self.dcpInfoList = [DCPInfo(**e) for e in rv]
|
|
||||||
|
|
||||||
class PowerStatus(BaseModel):
|
class PowerStatus(SMSMessage):
|
||||||
Device: Optional[str] = None
|
PowerStatus: List[PowerStatusWrapper]
|
||||||
State: Optional[str] = None
|
|
||||||
|
|
||||||
class PowerStatusList(BaseCommand):
|
|
||||||
powerStatusList: Optional[List[PowerStatus]] = None
|
|
||||||
path = ['status', 'sms', 'powerstatus']
|
path = ['status', 'sms', 'powerstatus']
|
||||||
|
|
||||||
def get(self):
|
class ShowStatusDetailWrapper(BaseModel):
|
||||||
rv = self.update()
|
Type: str
|
||||||
if isinstance(rv, dict):
|
Id: UUID
|
||||||
self.powerStatusList = [PowerStatus(**e) for e in rv['PowerStatus']]
|
RemainingTime: int
|
||||||
|
ElapsedTime: int
|
||||||
|
TotalDuration: int
|
||||||
|
CurrentEventId: UUID
|
||||||
|
CurrentEventType: str
|
||||||
|
IsStoppedByMalfunction: bool
|
||||||
|
RewindTimeList: List[int]
|
||||||
|
MalfunctionTime: int
|
||||||
|
|
||||||
class ShowStatusDetailClass(BaseModel):
|
@field_validator('RewindTimeList', mode='before')
|
||||||
Type: Optional[str] = None
|
@classmethod
|
||||||
Id: Optional[UUID] = None
|
def extract(cls, data: str) -> List[int]:
|
||||||
RemainingTime: Optional[int] = None
|
return [int(v) for v in data.split(',')]
|
||||||
ElapsedTime: Optional[int] = None
|
|
||||||
TotalDuration: Optional[int] = None
|
class ShowStatus(SMSMessage):
|
||||||
CurrentEventId: Optional[UUID] = None
|
PlayState: str
|
||||||
CurrentEventType: Optional[str] = None
|
ShowStatusDetail: ShowStatusDetailWrapper
|
||||||
IsStoppedByMalfunction: Optional[bool] = None
|
PlayBackMode: str
|
||||||
RewindTimeList: Optional[str] = None
|
AtmosPlayingStatus: str
|
||||||
MalfunctionTime: Optional[int] = None
|
|
||||||
|
|
||||||
class ShowStatus(BaseCommand):
|
|
||||||
PlayState: Optional[str] = None
|
|
||||||
ShowStatusDetail: Optional[ShowStatusDetailClass] = None
|
|
||||||
PlayBackMode: Optional[str] = None
|
|
||||||
AtmosPlayingStatus: Optional[str] = None
|
|
||||||
path = ['playback', 'showstatus']
|
path = ['playback', 'showstatus']
|
||||||
|
|
||||||
def get(self):
|
class ImportProgressWrapper(BaseModel):
|
||||||
rv = self.update()
|
TotalBytesToTransfer: int
|
||||||
if isinstance(rv, dict):
|
BytesTransferred: int
|
||||||
self.PlayState = rv['PlayState']
|
PercentCompleted: int
|
||||||
self.ShowStatusDetail = ShowStatusDetailClass(**rv['ShowStatusDetail'])
|
InProgress: bool
|
||||||
self.PlayBackMode = rv['PlayBackMode']
|
ImportPath: str
|
||||||
self.AtmosPlayingStatus = rv['AtmosPlayingStatus']
|
CompletionStatus: str
|
||||||
|
CompletionTime: Optional[datetime] = None
|
||||||
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
|
|
||||||
DCPTitle: Optional[str] = None
|
DCPTitle: Optional[str] = None
|
||||||
|
|
||||||
class ValidationProgressClass(BaseModel):
|
class ValidationProgress(BaseModel):
|
||||||
TotalBytesToValidate: Optional[int] = None
|
TotalBytesToValidate: int
|
||||||
BytesValidated: Optional[int] = None
|
BytesValidated: int
|
||||||
PercentCompleted: Optional[int] = None
|
PercentCompleted: int
|
||||||
InProgress: Optional[bool] = None
|
InProgress: bool
|
||||||
Id: Optional[UUID] = None
|
Id: UUID
|
||||||
CompletionStatus: Optional[str] = None
|
CompletionStatus: str
|
||||||
CompletionTime: Optional[datetime] = None
|
CompletionTime: datetime
|
||||||
|
|
||||||
|
type ValidationProgressElem = ValidationProgress
|
||||||
|
class ValidationProgressWrapper(BaseModel):
|
||||||
|
ValidationProgress: ValidationProgressElem
|
||||||
|
|
||||||
class JobProgress(BaseModel):
|
class JobProgress(BaseModel):
|
||||||
Id: Optional[int] = None
|
Id: int
|
||||||
ValidateAfterImport: Optional[bool] = None
|
ValidateAfterImport: bool
|
||||||
AggregatePercentValidated: Optional[int] = None
|
AggregatePercentValidated: int
|
||||||
State: Optional[str] = None
|
State: str
|
||||||
ImportProgress: Optional[ImportProgressClass] = None
|
ImportProgress: ImportProgressWrapper
|
||||||
ValidationProgressList: Optional[List[ValidationProgressClass]] = None
|
ValidationProgressList: ValidationProgressWrapper | None
|
||||||
IngestedByFolder: Optional[bool] = None
|
IngestedByFolder: bool
|
||||||
ContentsTransferType: Optional[str] = None
|
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']
|
path = ['content', 'dcp', 'command']
|
||||||
params = {'action': 'ListImportJobs'}
|
params = {'action': 'ListImportJobs'}
|
||||||
|
|
||||||
def get(self):
|
class StorageInfo(SMSMessage):
|
||||||
rv = self.update()
|
FileSystem: str
|
||||||
if isinstance(rv, dict):
|
TotalCapacity: int
|
||||||
self.IsPaused = rv['IsPaused']
|
SpaceInUse: int
|
||||||
self.JobProgressList = [JobProgress(**e) for e in rv['JobProgressList']]
|
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
|
||||||
|
|||||||
14
projmon/haconfig.py
Normal file
14
projmon/haconfig.py
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import projrequest as projrequest
|
import projrequest as projrequest
|
||||||
from commands import ShowStatus, PowerStatusList
|
from commands import ShowStatus, PowerStatus, DCPImportJobList, DCPInfoList, StorageInfo
|
||||||
from utils import *
|
from utils import *
|
||||||
from influxdb_client_3 import InfluxDBClient3, Point
|
from influxdb_client_3 import InfluxDBClient3, Point
|
||||||
|
|
||||||
@@ -39,20 +39,16 @@ def main() -> int:
|
|||||||
username=env['PROJECTOR_USER'],
|
username=env['PROJECTOR_USER'],
|
||||||
password=env['PROJECTOR_PASSWORD']
|
password=env['PROJECTOR_PASSWORD']
|
||||||
)
|
)
|
||||||
ps = PowerStatusList(projector=projector)
|
|
||||||
ss = ShowStatus(projector=projector)
|
|
||||||
while handler.running:
|
while handler.running:
|
||||||
try:
|
try:
|
||||||
now:float = time.time()
|
now:float = time.time()
|
||||||
|
ret = projector.request(ShowStatus)
|
||||||
ps.get()
|
print(ret)
|
||||||
ss.get()
|
|
||||||
print(ps.dump())
|
|
||||||
print(ss.dump())
|
|
||||||
|
|
||||||
last: float = time.time()
|
last: float = time.time()
|
||||||
cycle_time: float = last - now
|
cycle_time: float = last - now
|
||||||
LOGGER.debug(f"Cycle Time: {cycle_time:4.3f}")
|
LOGGER.debug(f"Cycle Time: {cycle_time:4.3f}")
|
||||||
|
if cycle_time < INTERVAL:
|
||||||
time.sleep(INTERVAL-cycle_time)
|
time.sleep(INTERVAL-cycle_time)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Unexpected exception: [{e}]")
|
print(f"Unexpected exception: [{e}]")
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import urllib3
|
|||||||
urllib3.disable_warnings(category=urllib3.exceptions.InsecureRequestWarning)
|
urllib3.disable_warnings(category=urllib3.exceptions.InsecureRequestWarning)
|
||||||
import xmltodict
|
import xmltodict
|
||||||
from requests.auth import HTTPBasicAuth
|
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():
|
class ProjectorConnection():
|
||||||
ip: str
|
ip: str
|
||||||
@@ -33,13 +39,24 @@ class ProjectorConnection():
|
|||||||
timeout=4,
|
timeout=4,
|
||||||
verify=False
|
verify=False
|
||||||
)
|
)
|
||||||
|
response.raise_for_status()
|
||||||
if response.status_code == 200: # HTTP ok response
|
if response.status_code == 200: # HTTP ok response
|
||||||
rv: dict = {}
|
return xmltodict.parse(response.text.replace("%2F", "/").replace("%3A", ":")) # Common containert for all messages
|
||||||
content: dict = xmltodict.parse(response.text)["SMSMessage"] # Common containert for all messages
|
return None
|
||||||
header: dict = content["MessageHeader"]
|
|
||||||
body: dict = content["MessageBody"]
|
def request(self, cls: Type[T]) -> T | None:
|
||||||
rv['type'] = header.get("Type", None)
|
rv: dict | None
|
||||||
rv['timestamp'] = int(header.get("Timestamp", datetime.now()))
|
if TEST:
|
||||||
rv['body'] = body[rv["type"]]
|
with open(f'{getcwd()}/projmon/xmlresponses/showstatus.xml', 'r') as fd:
|
||||||
return rv
|
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
|
return None
|
||||||
|
|||||||
16
projmon/xmlresponses/importjob_empty.xml
Normal file
16
projmon/xmlresponses/importjob_empty.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<SMSMessage xmlns="http://xmlns.sony.net/d-cinema/sms/2007b">
|
||||||
|
<MessageHeader>
|
||||||
|
<Id>-1</Id>
|
||||||
|
<Type>DCPImportJobList</Type>
|
||||||
|
<Timestamp>1771320721827</Timestamp>
|
||||||
|
<Source>LSM-100v2</Source>
|
||||||
|
</MessageHeader>
|
||||||
|
<MessageBody>
|
||||||
|
<DCPImportJobList>
|
||||||
|
<IsPaused>false</IsPaused>
|
||||||
|
<CurrentJobIdList></CurrentJobIdList>
|
||||||
|
<JobProgressList></JobProgressList>
|
||||||
|
</DCPImportJobList>
|
||||||
|
</MessageBody>
|
||||||
|
</SMSMessage>
|
||||||
36
projmon/xmlresponses/importjob_pause.xml
Normal file
36
projmon/xmlresponses/importjob_pause.xml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<SMSMessage xmlns="http://xmlns.sony.net/d-cinema/sms/2007b">
|
||||||
|
<MessageHeader>
|
||||||
|
<Id>-1</Id>
|
||||||
|
<Type>DCPImportJobList</Type>
|
||||||
|
<Timestamp>1771321131757</Timestamp>
|
||||||
|
<Source>LSM-100v2</Source>
|
||||||
|
</MessageHeader>
|
||||||
|
<MessageBody>
|
||||||
|
<DCPImportJobList>
|
||||||
|
<IsPaused>true</IsPaused>
|
||||||
|
<CurrentJobIdList></CurrentJobIdList>
|
||||||
|
<JobProgressList>
|
||||||
|
<JobProgress>
|
||||||
|
<Id>0</Id>
|
||||||
|
<ValidateAfterImport>true</ValidateAfterImport>
|
||||||
|
<AggregatePercentValidated>0</AggregatePercentValidated>
|
||||||
|
<State>PAUSED</State>
|
||||||
|
<ImportProgress>
|
||||||
|
<TotalBytesToTransfer>197696878067</TotalBytesToTransfer>
|
||||||
|
<BytesTransferred>4904562323</BytesTransferred>
|
||||||
|
<PercentCompleted>2</PercentCompleted>
|
||||||
|
<InProgress>false</InProgress>
|
||||||
|
<ImportPath>ftp%3A%2F192.168.31.16%2FGRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV</ImportPath>
|
||||||
|
<CompletionStatus>CANCELLED</CompletionStatus>
|
||||||
|
<CompletionTime>2026-02-17T10:38:40.210+01:00</CompletionTime>
|
||||||
|
<DCPTitle>GRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV</DCPTitle>
|
||||||
|
</ImportProgress>
|
||||||
|
<ValidationProgressList></ValidationProgressList>
|
||||||
|
<IngestedByFolder>true</IngestedByFolder>
|
||||||
|
<ContentsTransferType>FTPINGEST</ContentsTransferType>
|
||||||
|
</JobProgress>
|
||||||
|
</JobProgressList>
|
||||||
|
</DCPImportJobList>
|
||||||
|
</MessageBody>
|
||||||
|
</SMSMessage>
|
||||||
38
projmon/xmlresponses/importjob_run.xml
Normal file
38
projmon/xmlresponses/importjob_run.xml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<SMSMessage xmlns="http://xmlns.sony.net/d-cinema/sms/2007b">
|
||||||
|
<MessageHeader>
|
||||||
|
<Id>-1</Id>
|
||||||
|
<Type>DCPImportJobList</Type>
|
||||||
|
<Timestamp>1771321082485</Timestamp>
|
||||||
|
<Source>LSM-100v2</Source>
|
||||||
|
</MessageHeader>
|
||||||
|
<MessageBody>
|
||||||
|
<DCPImportJobList>
|
||||||
|
<IsPaused>false</IsPaused>
|
||||||
|
<CurrentJobIdList>
|
||||||
|
<Id>0</Id>
|
||||||
|
</CurrentJobIdList>
|
||||||
|
<JobProgressList>
|
||||||
|
<JobProgress>
|
||||||
|
<Id>0</Id>
|
||||||
|
<ValidateAfterImport>true</ValidateAfterImport>
|
||||||
|
<AggregatePercentValidated>0</AggregatePercentValidated>
|
||||||
|
<State>IMPORTING</State>
|
||||||
|
<ImportProgress>
|
||||||
|
<TotalBytesToTransfer>197696878067</TotalBytesToTransfer>
|
||||||
|
<BytesTransferred>998483110</BytesTransferred>
|
||||||
|
<PercentCompleted>1</PercentCompleted>
|
||||||
|
<InProgress>true</InProgress>
|
||||||
|
<ImportPath>ftp%3A%2F192.168.31.16%2FGRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV</ImportPath>
|
||||||
|
<CompletionStatus>NONE</CompletionStatus>
|
||||||
|
<DCPTitle>GRAZIA_FTR_S_IT-XX_IT_51_4K_ST_20251021_FLA_SMPTE_OV</DCPTitle>
|
||||||
|
</ImportProgress>
|
||||||
|
<ValidationProgressList></ValidationProgressList>
|
||||||
|
<IngestedByFolder>true</IngestedByFolder>
|
||||||
|
<ContentsTransferType>FTPINGEST</ContentsTransferType>
|
||||||
|
</JobProgress>
|
||||||
|
</JobProgressList>
|
||||||
|
</DCPImportJobList>
|
||||||
|
</MessageBody>
|
||||||
|
</SMSMessage>
|
||||||
|
|
||||||
Reference in New Issue
Block a user