Skip to content

Commit

Permalink
Add ip auto discovery + query projector data asynchronously
Browse files Browse the repository at this point in the history
  • Loading branch information
kennymc-c committed May 11, 2024
1 parent dd66c30 commit 087fed8
Show file tree
Hide file tree
Showing 7 changed files with 119 additions and 90 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

*Changes in the next release*

## [0.6-beta] - 2024-05-11

### Added
- Added IP auto discovery. Just leave the ip text field empty in the setup process

### Changed
- No need to change the default SDAP advertisement interval anymore on the projector as the query function is now running asynchronously in a separate thread

### Fixed
- Replaced a variable reference log warning message with a clearer error description. This message appears if attributes of an entity should be updated that the remote has not yet subscribed to, e.g. shortly after the integration setup has been completed or if the integration configuration has been deleted just on the remote side.

## [0.5-beta] - 2024-05-05

### Changed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ By default the integration checks the status of all attributes every 20 seconds.
### Planned features:

- Picture position and advanced iris commands (needs testers as I only own a VPL-VW-270 that doesn't support lens memory and iris control)
- Auto discovery of the projector
- Additional sensor entity to show the lamp time
- Additional remote entity to automatically map all commands to buttons and the ui grid

*Planned improvements are labeled with #TODO in the code*

Expand Down Expand Up @@ -101,7 +101,7 @@ Open the projectors web interface and go to _Setup/Advanced Menu (left menu)/PJT

#### Change SDAP Interval

During the initial setup the integration tries to query data from the projector via the SDAP advertisement protocol to generate a unique entity id. The default SDAP interval is 30 seconds. This relatively long interval can lead to heartbeat timeouts to the remotes websocket integration api. The workaround is to shorten the interval to the minimum value of 10 seconds under _Setup/Advanced Menu/Advertisement/Interval_.
During the initial setup the integration tries to query data from the projector via the SDAP advertisement protocol to generate a unique entity id. The default SDAP interval is 30 seconds. You can shorten the interval to a minimum value of 10 seconds under _Setup/Advanced Menu/Advertisement/Interval_.

![advertisement](advertisement.png)

Expand Down
8 changes: 4 additions & 4 deletions intg-sonysdcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
_LOG = logging.getLogger(__name__)

CFG_FILENAME = "config.json"
#TODO Add entity features, attributes and option/simple commands

SDCP_PORT = 53484 #Currently only used for port check during setup
SDAP_PORT = 53862 #Currently only used for port check during setup
POLLER_INTERVAL = 20 #Set to 0 to deactivate

#TODO Integrate SDCP and SDAP port and PJTalk community as variables into the command assigner to replace the pySDCP variables



class setup:
__conf = {
"ip": "",
Expand Down Expand Up @@ -56,8 +56,8 @@ def set(key, value):

#Create config file first if it doesn't exists yet
else:
#Skip storing setup_complete if no ip has ben set before
if key != "setup_complete" and setup.__conf["ip"] != "":
#Skip storing setup_complete if no config files exists
if key != "setup_complete":
try:
with open(CFG_FILENAME, "w") as f:
json.dump(jsondata, f)
Expand Down
3 changes: 3 additions & 0 deletions intg-sonysdcp/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ async def main():

_LOG.debug("Starting driver")

#TODO Remove all pySDCP files and add pySDCP to requirements.txt when upstream PR has been merged:
#https://github.com/Galala7/pySDCP/pull/5

await setup.init()
await startcheck()

Expand Down
26 changes: 19 additions & 7 deletions intg-sonysdcp/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,17 @@ async def update_attributes(id: str):
except Exception as e:
raise Exception(e)

stored_states = await driver.api.configured_entities.get_states()
for entity in stored_states:
attributes_stored = entity["attributes"]
try:
#TODO Change to configured_entities once the core supports this feature
stored_states = await driver.api.available_entities.get_states()
except Exception as e:
raise Exception(e)

if stored_states != []:
for entity in stored_states:
attributes_stored = entity["attributes"]
else:
raise Exception("Got empty states from remote. Please make sure to add the entity as a configured entity")

stored_attributes = {"state": attributes_stored["state"], "muted": attributes_stored["muted"], "source": attributes_stored["source"]}
current_attributes = {"state": state, "muted": muted, "source": source}
Expand Down Expand Up @@ -92,10 +100,16 @@ async def update_attributes(id: str):
attributes_to_send.update({ucapi.media_player.Attributes.SOURCE: source})

try:
driver.api.configured_entities.update_attributes(id, attributes_to_send)
_LOG.info("Updated entity attributes " + str(attributes_to_update) + " for " + id)
update_attributes = driver.api.configured_entities.update_attributes(id, attributes_to_send)
except:
raise Exception("Error while updating attributes for entity id " + id)

if not update_attributes:
raise Exception("Entity " + id + " not found. Please make sure it's added as a configured entity on the remote")
else:
_LOG.info("Updated entity attributes " + str(attributes_to_update) + " for " + id)


else:
_LOG.info("No entity attributes to update")

Expand Down Expand Up @@ -196,8 +210,6 @@ def cmd_error(msg: str = None):
_LOG.error(msg)
return ucapi.StatusCodes.BAD_REQUEST

#TODO If all commands from protocol.py have been implemented into pySDCP as separate commands create a upstream pull request and remove pySDCP files when it has been merged

match cmd_name:

case ucapi.media_player.Commands.ON:
Expand Down
12 changes: 6 additions & 6 deletions intg-sonysdcp/setup.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"driver_id": "sonysdcp",
"version": "0.5",
"release_date": "2024-05-05",
"version": "0.6",
"release_date": "2024-05-11",
"min_core_api": "0.24.3",
"name": {
"en": "Sony Projector",
Expand All @@ -27,17 +27,17 @@
{
"id": "ip",
"label": {
"en": "Enter the IP address of the projector",
"de": "Gib die IP-Adresse des Projektors ein"
"en": "Enter the IP address of the projector (leave blank for auto discovery):",
"de": "Gib die IP-Adresse des Projektors ein (leer lassen für eine automatische Erkennung):"
},
"field": {"text": {"value": ""}}
},
{
"id": "note",
"label": {"en": "Note", "de": "Hinweis"},
"field": { "label": { "value": {
"en": "By clicking on next, the serial number and model name is requested from the projector. This data is only send **every 30 seconds** by default and the interval should therefore be shortened to a minimum of **10 seconds** in the web interface of the projector under _Setup/Advanced Menu/Advertisement/Interval_ to avoid timeouts to the remote and the integration",
"de": "Durch den Klick auf Weiter werden die Seriennummer und der Name des Models vom Projektor abgerufen. Diese Daten werden standardmäßig nur **alle 30 Sekunden** gesendet und der Interval sollte deshalb im Webinterface des Projektors unter _Setup/Advanced Menu/Advertisement/Interval_ auf minimal **10 Sekunden** verkürzt werden, um Timeouts zur Remote und der Integration zu verhindern."
"en": "By clicking on next, serial number, model name and if necessary the ip address are requested from the projector. This data is only send **every 30 seconds** by default. The interval can be shortened to a minimum of **10 seconds** in the web interface of the projector under _Setup/Advanced Menu/Advertisement/Interval_",
"de": "Durch den Klick auf Weiter werden die Seriennummer, der Modellname und ggfs. die IP vom Projektor abgerufen. Diese Daten werden standardmäßig nur **alle 30 Sekunden** gesendet. Der Interval kann im Webinterface des Projektors unter _Setup/Advanced Menu/Advertisement/Interval_ auf minimal **10 Sekunden** verkürzt werden."
} }
}
}
Expand Down
145 changes: 74 additions & 71 deletions intg-sonysdcp/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,15 @@ async def handle_driver_setup(msg: ucapi.DriverSetupRequest,) -> ucapi.SetupActi

ip = msg.setup_data["ip"]

if ip == "":
_LOG.error("No IP address has been entered")
return ucapi.SetupError(error_type=ucapi.IntegrationSetupError.NOT_FOUND)
else:
if ip != "":
#Check if input is a valid ipv4 or ipv6 address
try:
ip_object = ipaddress.ip_address(ip)
except ValueError:
_LOG.error("The entered ip address \"" + ip + "\" is not valid")
return ucapi.SetupError(error_type=ucapi.IntegrationSetupError.NOT_FOUND)

_LOG.info("Chosen ip address: " + ip)
_LOG.info("Entered ip address: " + ip)

#Check if SDCP/SDAP ports are open on the entered ip address
_LOG.info("Check if SDCP Port " + str(config.SDCP_PORT) + " is open")
Expand All @@ -96,83 +93,89 @@ async def handle_driver_setup(msg: ucapi.DriverSetupRequest,) -> ucapi.SetupActi
return ucapi.SetupError(error_type=ucapi.IntegrationSetupError.CONNECTION_REFUSED)

#TODO Modify port_check() to also work with UDP used for SDAP
# if not port_check(ip, config.SDAP_PORT):
# _LOG.error("Timeout while connecting to SDAP port " + str(config.SDAP_PORT) + " on " + ip)
# _LOG.info("Please check if you entered the correct ip of the projector and if SDAP advertisement is active and running on port " + str(config.SDAP_PORT))
# return ucapi.SetupError(error_type=ucapi.IntegrationSetupError.CONNECTION_REFUSED)

#Store ip in runtime and file storage
try:
config.setup.set("ip", ip)
except Exception as e:
_LOG.error(e)
return ucapi.SetupError()

#Get id and name from projector

#TODO Run get_pjinfo as a coroutine in the background to avoid a websocket heartbeat pong timeout because of SDAP's 30 second default advertisement interval. Doesn't work...

# cloop = asyncio.get_running_loop()
# cloop.run_until_complete(get_pjinfo(ip))

#Backup solution without a coroutine:
#This requires the user to set the SDAP interval to a lower value than the default 30 seconds (e.g. the minimum value of 10 seconds) to not to interfere with the faster websockets heartbeat interval that will drop the connection before

try:
await get_pjinfo(ip)
except TimeoutError as t:
_LOG.info("Please check if SDAP advertisement is running on the projector")
_LOG.error(t)
return ucapi.SetupError(error_type=ucapi.IntegrationSetupError.TIMEOUT)
except Exception as e:
_LOG.error(e)
return ucapi.SetupError()


else:
_LOG.info("No ip address entered. Using auto discovery mode")


try:
#Run blocking function set_entity_data which may need to run up to 30 seconds asynchronously in a separate thread to be able to still respond to the websocket server heartbeat ping messages in the meantime and prevent a disconnect from the websocket server
await asyncio.gather(asyncio.to_thread(set_entity_data, ip), asyncio.sleep(1))
except TimeoutError as t:
_LOG.info("No response from the projector. Please check if SDAP advertisement is activated on the projector")
_LOG.error(t)
return ucapi.SetupError(error_type=ucapi.IntegrationSetupError.TIMEOUT)
except Exception as e:
_LOG.error(e)
return ucapi.SetupError()

try:
ip = config.setup.get("ip")
id = config.setup.get("id")
name = config.setup.get("name")
except Exception as e:
_LOG.error(e)
return ucapi.SetupError()

_LOG.info("Add media player entity with id " + id + " and name " + name)
await media_player.add_mp(id, name)
_LOG.info("Add media player entity with id " + id + " and name " + name)
await media_player.add_mp(id, name)

if config.POLLER_INTERVAL == 0:
_LOG.info("POLLER_INTERVAL set to " + str(config.POLLER_INTERVAL) + ". Skip creation of attributes poller task")
else:
driver.loop.create_task(driver.attributes_poller(config.setup.get("id"), config.POLLER_INTERVAL))
_LOG.debug("Created attributes poller task with an interval of " + str(config.POLLER_INTERVAL) + " seconds")
if config.POLLER_INTERVAL == 0:
_LOG.info("POLLER_INTERVAL set to " + str(config.POLLER_INTERVAL) + ". Skip creation of attributes poller task")
else:
driver.loop.create_task(driver.attributes_poller(config.setup.get("id"), config.POLLER_INTERVAL))
_LOG.debug("Created attributes poller task with an interval of " + str(config.POLLER_INTERVAL) + " seconds")

config.setup.set("setup_complete", True)
_LOG.info("Setup complete")
return ucapi.SetupComplete()
config.setup.set("setup_complete", True)
_LOG.info("Setup complete")
return ucapi.SetupComplete()



async def get_pjinfo(ip: str):
_LOG.info("Query serial number and model name from projector (" + ip + ") via SDAP advertisement service")
_LOG.info("This may take up to 30 seconds depending on the interval setting of the projector")
def set_entity_data(man_ip: str = None):
_LOG.info("Query data from projector via SDAP advertisement service")
_LOG.info("This may take up to 30 seconds depending on the advertisement interval setting of the projector")

try:
pjinfo = pysdcp.Projector(ip).get_pjinfo()
try:
pjinfo = pysdcp.Projector(man_ip).get_pjinfo()
except Exception as e:
raise TimeoutError(e)

_LOG.debug("Got data from projector")
if "serial" and "model" in pjinfo:
if pjinfo["model"] or str(pjinfo["serial"]) != "":
id = pjinfo["model"] + "-" + str(pjinfo["serial"])
name= "Sony " + pjinfo["model"]
else:
raise Exception("Got empty id and name")

_LOG.debug("Generated ID and name from serial and model")
_LOG.debug("ID: " + id)
_LOG.debug("Name: " + name)

try:
config.setup.set("id", id)
config.setup.set("name", name)
except Exception as e:
raise Exception(e)
if pjinfo != "":
_LOG.info("Got data from projector")
if "serial" and "model" and "ip" in pjinfo:
if man_ip == "":
if pjinfo["ip"] != "":
ip = pjinfo["ip"]

_LOG.debug("Auto discovered IP: " + ip)
else:
raise Exception("Got empty ip from projector")
else:
_LOG.debug("Manually entered IP: " + man_ip)

if pjinfo["model"] or str(pjinfo["serial"]) != "":
id = pjinfo["model"] + "-" + str(pjinfo["serial"])
name= "Sony " + pjinfo["model"]

_LOG.debug("Generated ID and name from serial and model")
_LOG.debug("ID: " + id)
_LOG.debug("Name: " + name)
else:
raise Exception("Got empty model and serial from projector")

try:
if man_ip == "":
config.setup.set("ip", ip)
else:
config.setup.set("ip", man_ip)
config.setup.set("id", id)
config.setup.set("name", name)
except Exception as e:
raise Exception(e)

return True

else:
raise Exception("Unknown values from projector: " + pjinfo)
else:
raise Exception("Unknown values from projector: " + pjinfo)
raise Exception("Got no data from projector")

0 comments on commit 087fed8

Please sign in to comment.