Refactored pydantic implementation, cleaner with nested validation

This commit is contained in:
2026-02-18 11:18:06 +01:00
parent 501ba01bed
commit 1e874d6c5f
8 changed files with 247 additions and 122 deletions

2
.vscode/launch.json vendored
View File

@@ -43,7 +43,7 @@
"name": "Python Debugger: PROJmon",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"program": "${workspaceFolder}/projmon/projmon.py",
"console": "integratedTerminal",
"env": {
"INTERVAL" : "5",

View File

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

14
projmon/haconfig.py Normal file
View 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"
}
}

View File

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

View File

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

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

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

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