Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] lares 4 proof of concept #4

Draft
wants to merge 5 commits into
base: development/4support
Choose a base branch
from

Conversation

glsorre
Copy link

@glsorre glsorre commented Jan 9, 2025

Incomplete...but enough to start a discussion.

@tosomax
Copy link

tosomax commented Jan 9, 2025

Incomplete...but enough to start a discussion.

i've tested (to the best of my knowledge and with help from chatgpt) your second version.

This is the test file i'm using:

import pytest
import asyncio
from ksenia_lares.lares4_api import Lares4API

@pytest.mark.asyncio
async def test_connect():
"""Test the connection to the Lares4 device."""
config = {
"url": "192.168.1.90", # Replace with your Lares4 device IP
"pin": "XXX", # Replace with the correct PIN
"sender": "Massimo" # Replace with a valid sender ID
}
api_instance = Lares4API(config)

await api_instance.connect()
assert api_instance.is_running, "Connection failed"
print("Connection established successfully.")

await api_instance.close()
assert not api_instance.is_running, "Disconnection failed"
print("Connection closed successfully.")

@pytest.mark.asyncio
async def test_login():
"""Test the login functionality."""
config = {
"url": "192.168.1.90",
"pin": "XXX",
"sender": "Massimo"
}
api_instance = Lares4API(config)

try:
    await api_instance.connect()
    await api_instance.run()  # Perform login

    login_id = api_instance.command_factory.get_login_id()
    assert login_id is not None, "Login failed, ID_LOGIN not set"
    print(f"Login successful, ID_LOGIN: {login_id}")
finally:
    await api_instance.close()

@pytest.mark.asyncio
async def test_get_scenarios():
"""Test fetching scenarios."""
config = {
"url": "192.168.1.90",
"pin": "XXX",
"sender": "Massimo"
}
api_instance = Lares4API(config)

try:
    await api_instance.connect()
    await api_instance.run()  # Perform login

    scenarios = await api_instance.get_scenarios()
    assert scenarios is not None, "Failed to fetch scenarios"
    print(f"Scenarios fetched successfully: {scenarios}")
finally:
    await api_instance.close()

@pytest.mark.asyncio
async def test_get_partitions():
"""Test fetching partitions."""
config = {
"url": "192.168.1.90",
"pin": "XXX",
"sender": "Massimo"
}
api_instance = Lares4API(config)

try:
    await api_instance.connect()
    await api_instance.run()  # Perform login

    partitions = await api_instance.get_partitions()
    assert partitions is not None, "Failed to fetch partitions"
    print(f"Partitions fetched successfully: {partitions}")
finally:
    await api_instance.close()

async def test_get_zones():
config = {
"url": "192.168.1.90",
"pin": "XXX",
"sender": "Massimo"
}
api_instance = Lares4API(config)

try:
    await api_instance.connect()
    await api_instance.run()  # Perform login

    zones = await api_instance.get_zones()
    assert zones is not None, "Failed to fetch zones"
    print(f"Zones fetched successfully: {zones}")
finally:
    await api_instance.close()

async def test_info():
config = {
"url": "192.168.1.90",
"pin": "XXX",
"sender": "Massimo"
}
api_instance = Lares4API(config)

try:
    await api_instance.connect()
    await api_instance.run()  # Perform login

    info = await api_instance.info()
    assert info is not None, "Failed to fetch info"
    print(f"Info fetched successfully: {info}")
finally:
    await api_instance.close()

I'm able to connect and get all the data!!!
I don't understand what "sender" is supposed to be.
It seems I'm able to log in only with the "installer code", and not the one I use daily. Do you have an idea why?

Anyway, these are the responses and the data I get from the API:

LOGIN:
{'SENDER': '000350D51278', 'RECEIVER': 'Massimo', 'CMD': 'LOGIN_RES', 'ID': '1', 'PAYLOAD_TYPE': 'INSTALLER', 'PAYLOAD': {'RESULT': 'OK', 'RESULT_DETAIL': 'LOGIN_OK', 'ID_LOGIN': '2', 'LEV': 'I', 'DESCRIPTION': 'installatore', 'PRT': 'ALL', 'SYSTEM_LANG': 'ITI', 'POSITION': {'LAT': '0.0000000', 'LONG': '0.0000000'}, 'SESSION_STATE': 'CLOSE', 'FREEZE_STATE': 'OFF', 'CONNECTION': 'LOCAL', 'MAX_IP_CAMERAS': '0', 'MAINTENANCE': 'F', 'PRG_CHECK': {'PRG': '0000000340', 'CFG': '0000000154'}, 'VER_LITE': {'FW': '1.106.53', 'WS_REQ': '0.0.0', 'WS': '1.33.15', 'VM_REQ': '0.0.0', 'VM': '???'}}, 'TIMESTAMP': '1736461259', 'CRC_16': '0x9E2A'}

SCENARIOS:

Sent: {'SENDER': 'Massimo', 'RECEIVER': '', 'CMD': 'READ', 'ID': '2', 'PAYLOAD_TYPE': 'MULTI_TYPES', 'PAYLOAD': {'ID_LOGIN': '2', 'ID_READ': '1', 'TYPES': ['SCENARIOS']}, 'TIMESTAMP': '1736461258', 'CRC_16': '0xe8eb'}
Scenarios fetched successfully: {'SENDER': '000350D51278', 'RECEIVER': 'Massimo', 'CMD': 'READ_RES', 'ID': '2', 'PAYLOAD_TYPE': 'MULTI_TYPES', 'PAYLOAD': {'RESULT': 'OK', 'RESULT_DETAIL': 'READ_OK', 'SCENARIOS': [{'ID': '1', 'DES': 'Disinserimento', 'PIN': 'P', 'CAT': 'DISARM'}, {'ID': '2', 'DES': 'Inserisci', 'PIN': 'P', 'CAT': 'ARM'}, {'ID': '3', 'DES': 'Modo Notte', 'PIN': 'P', 'CAT': 'ARM'}, {'ID': '4', 'DES': 'Garage', 'PIN': 'P', 'CAT': 'PARTIAL'}], 'PRG_CHECK': {'PRG': '0000000340', 'CFG': '0000000154'}}, 'TIMESTAMP': '1736461260', 'CRC_16': '0x3654'}
Connection closed

PARTITIONS:

Sent: {'SENDER': 'Massimo', 'RECEIVER': '', 'CMD': 'READ', 'ID': '2', 'PAYLOAD_TYPE': 'MULTI_TYPES', 'PAYLOAD': {'ID_LOGIN': '2', 'ID_READ': '1', 'TYPES': ['PARTITIONS']}, 'TIMESTAMP': '1736461259', 'CRC_16': '0x5e8c'}
Partitions fetched successfully: {'SENDER': '000350D51278', 'RECEIVER': 'Massimo', 'CMD': 'READ_RES', 'ID': '2', 'PAYLOAD_TYPE': 'MULTI_TYPES', 'PAYLOAD': {'RESULT': 'OK', 'RESULT_DETAIL': 'READ_OK', 'PARTITIONS': [{'ID': '1', 'DES': 'Sensori Perimetrali', 'TOUT': '1', 'TIN': '1'}, {'ID': '2', 'DES': 'Sensori Volumetrici', 'TOUT': '1', 'TIN': '1'}, {'ID': '3', 'DES': 'Ingresso', 'TOUT': '120', 'TIN': '15'}, {'ID': '4', 'DES': 'Garage', 'TOUT': '600', 'TIN': '30'}, {'ID': '5', 'DES': 'Lavanderia', 'TOUT': '1', 'TIN': '1'}], 'PRG_CHECK': {'PRG': '0000000340', 'CFG': '0000000154'}}, 'TIMESTAMP': '1736461260', 'CRC_16': '0x1B08'}
Connection closed

ZONES:

Sent: {'SENDER': 'Massimo', 'RECEIVER': '', 'CMD': 'READ', 'ID': '2', 'PAYLOAD_TYPE': 'MULTI_TYPES', 'PAYLOAD': {'ID_LOGIN': '1', 'ID_READ': '1', 'TYPES': ['ZONES']}, 'TIMESTAMP': '1736461464', 'CRC_16': '0x2666'}
Zones fetched successfully: {'SENDER': '000350D51278', 'RECEIVER': 'Massimo', 'CMD': 'READ_RES', 'ID': '2', 'PAYLOAD_TYPE': 'MULTI_TYPES', 'PAYLOAD': {'RESULT': 'OK', 'RESULT_DETAIL': 'READ_OK', 'ZONES': [{'ID': '1', 'DES': 'Porta Blindata', 'PRT': '4', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}, {'ID': '2', 'DES': 'Soggiorno', 'PRT': '1', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}, {'ID': '3', 'DES': 'Cucina', 'PRT': '1', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}, {'ID': '4', 'DES': 'Bagni', 'PRT': '1', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}, {'ID': '5', 'DES': 'Camera', 'PRT': '1', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}, {'ID': '6', 'DES': 'Studio', 'PRT': '1', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}, {'ID': '7', 'DES': 'Lavanderia', 'PRT': '10', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}, {'ID': '8', 'DES': 'Garage', 'PRT': '8', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}, {'ID': '9', 'DES': 'Tenda Lavanderia', 'PRT': '2', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}, {'ID': '10', 'DES': 'Tenda Caldaia', 'PRT': '2', 'CMD': 'F', 'BYP_EN': 'T', 'CAT': 'GEN', 'AN': 'F'}], 'PRG_CHECK': {'PRG': '0000000340', 'CFG': '0000000154'}}, 'TIMESTAMP': '1736461465', 'CRC_16': '0x54E4'}
Connection closed

INFO

Sent: {'SENDER': 'Massimo', 'RECEIVER': '', 'CMD': 'REALTIME', 'ID': '2', 'PAYLOAD_TYPE': 'REGISTER', 'PAYLOAD': {'ID_LOGIN': '1', 'TYPES': ['STATUS_SYSTEM']}, 'TIMESTAMP': '1736461876', 'CRC_16': '0xe2ab'}
Info fetched successfully: {'SENDER': '000350D51278', 'RECEIVER': 'Massimo', 'CMD': 'REALTIME_RES', 'ID': '2', 'PAYLOAD_TYPE': 'REGISTER_ACK', 'PAYLOAD': {'RESULT': 'OK', 'RESULT_DETAIL': 'REALTIME_START_OK', 'STATUS_SYSTEM': [{'ID': '1', 'INFO': [], 'TAMPER': [], 'TAMPER_MEM': [], 'ALARM': [], 'ALARM_MEM': [], 'FAULT': [], 'FAULT_MEM': [], 'ARM': {'D': 'Disinserito', 'S': 'D'}, 'TEMP': {'IN': '+19.7', 'OUT': '+8.0'}, 'TIME': {'GMT': '1736461877', 'TZ': '1', 'TZM': '60', 'DAWN': 'NA', 'DUSK': 'NA'}}]}, 'TIMESTAMP': '1736461877', 'CRC_16': '0x6B0E'}
Connection closed

@glsorre
Copy link
Author

glsorre commented Jan 10, 2025

Hello @tosomax, have you tested it against a BTicino 4200?

Your tests are OK. Well done. I think I am missing 2 functions to cover the BaseAPI interface. I need to a bit of time to recover the payloads.

As to the sender concerns...I have no official doc but being a websocket I imagine it is just a way to "mark" to whom the message is sent.

Furthermore I would like to discuss the gaps between the types defined by @johnnybegood and the lares4 models. We should discuss how to solve this aspect.

Other question is: are you gonna support only the alarm system or also domotic entities? In the first case I will need to put some filters here and there.

PS: it should work with a normal pin...I would check permissions.

@tosomax
Copy link

tosomax commented Jan 10, 2025

Hello @tosomax, have you tested it against a BTicino 4200?

Your tests are OK. Well done. I think I am missing 2 functions to cover the BaseAPI interface. I need to a bit of time to recover the payloads.

As to the sender concerns...I have no official doc but being a websocket I imagine it is just a way to "mark" to whom the message is sent.

Furthermore I would like to discuss the gaps between the types defined by @johnnybegood and the lares4 models. We should discuss how to solve this aspect.

Other question is: are you gonna support only the alarm system or also domotic entities? In the first case I will need to put some filters here and there.

PS: it should work with a normal pin...I would check permissions.

Yes I'm using 4200C from BTicino. I will check if there Is some permission to change in the installation page to allow login with a normal PIN and let you know

Thanks again for helping

@johnnybegood
Copy link
Owner

Hello @tosomax, have you tested it against a BTicino 4200?

Your tests are OK. Well done. I think I am missing 2 functions to cover the BaseAPI interface. I need to a bit of time to recover the payloads.

As to the sender concerns...I have no official doc but being a websocket I imagine it is just a way to "mark" to whom the message is sent.

Furthermore I would like to discuss the gaps between the types defined by @johnnybegood and the lares4 models. We should discuss how to solve this aspect.

Other question is: are you gonna support only the alarm system or also domotic entities? In the first case I will need to put some filters here and there.

PS: it should work with a normal pin...I would check permissions.

We can definitely expose more than the alarm. It can be something we add to the model, the IpApi can just reply on that call with less or no sensors (there are few unexposed sensors today, but nothing home automation related)

@tosomax
Copy link

tosomax commented Jan 10, 2025

Hello @tosomax, have you tested it against a BTicino 4200?

Your tests are OK. Well done. I think I am missing 2 functions to cover the BaseAPI interface. I need to a bit of time to recover the payloads.

As to the sender concerns...I have no official doc but being a websocket I imagine it is just a way to "mark" to whom the message is sent.

Furthermore I would like to discuss the gaps between the types defined by @johnnybegood and the lares4 models. We should discuss how to solve this aspect.

Other question is: are you gonna support only the alarm system or also domotic entities? In the first case I will need to put some filters here and there.

PS: it should work with a normal pin...I would check permissions.

I have tried to see in the installation menu if there is some way to allow permission to connect remotely for my user, but I can't find anything wrong. The PIN i'm trying to use is a "master" user, and with this the login fails. However, if I just use a plain wrong PIN I can see the error related to the failed login attempt in the history of my alarm app, if I use a correct PIN (the master user) I have a failed login, but no error message on the app.

Anyway, I have confirmed that the login is possible and it can receive data. Maybe now is not the time to troubleshoot this kind of issues, so whenever you need some testing, please let me know.

Again I want to thank both of you for your efforts

@johnnybegood
Copy link
Owner

johnnybegood commented Jan 11, 2025

Let's start with mapping the zones. So what we need to figure out is what each of these fields mean and what values they have for the cases:

  • Idle
  • Bypassed
  • Alarm/Triggerd

Extracted part:

{
  "ID": "1",
  "DES": "Porta Blindata",
  "PRT": "4",
  "CMD": "F",
  "BYP_EN": "T",
  "CAT": "GEN",
  "AN": "F"
}

Guess of chatgpt:
Each zone in the "ZONES" array has the following properties:
-ID: A unique identifier for the zone.

  • DES: Description or name of the zone (e.g., "Porta Blindata" or "Garage").
  • PRT: Port or partition number associated with the zone.
  • CMD: Status or command for the zone. In this data, all zones have the value 'F', which might indicate "Fault" or "Inactive."
  • BYP_EN: Bypass enabled status. 'T' indicates bypass is enabled for all zones.
  • CAT: Category of the zone. All zones are of type 'GEN', which might mean "General."
  • AN: An undefined flag, possibly for alarms. 'F' likely indicates "False" or "Disabled."

@tosomax @glsorre can you guys for 1 zone get exports when triggered (with alarm off) and when idle + maybe with bypass on and off?

@johnnybegood johnnybegood marked this pull request as draft January 11, 2025 15:59
@johnnybegood johnnybegood self-requested a review January 11, 2025 15:59
@johnnybegood johnnybegood added the enhancement New feature or request label Jan 11, 2025
@johnnybegood johnnybegood linked an issue Jan 11, 2025 that may be closed by this pull request
@johnnybegood
Copy link
Owner

@glsorre can you point this PR to development/4support branch? Then I can import it as WIP

@glsorre glsorre changed the base branch from main to development/4support January 11, 2025 17:58
@glsorre
Copy link
Author

glsorre commented Jan 11, 2025

Hi @johnnybegood, I moved the PR to the other branch.
In lares4-ts I do not manage the alarm for choice so I really didn't go into zones and partitions.

BTW playing with the domotic part of the system I understood that there are 2 different types of payload commands:

  • READ/MULTI_TYPES that gives you a static description of what you put into the TYPES array
{
   'SENDER': 'Massimo',
   'RECEIVER': '',
   'CMD': 'READ',
   'ID': '2',
   'PAYLOAD_TYPE': 'MULTI_TYPES',
   'PAYLOAD': {'ID_LOGIN': '1', 'ID_READ': '1', 'TYPES': ['ZONES']},
   'TIMESTAMP': '1736461464',
   'CRC_16': '0x2666'
}
  • REALTIME/REGISTER that register your sender to receive updates about what you insert in the TYPES array
{
  "SENDER":"XXX",
  "RECEIVER":"",            
  "CMD":"REALTIME",
  "ID": "42", 
  "PAYLOAD_TYPE":"REGISTER",
  "PAYLOAD":{"ID_LOGIN":"3","TYPES":["STATUS_ZONES"]},
  "TIMESTAMP":"1736618680",
  "CRC_16":"0xea1e"
}

If you send the second type of request on the WebSocket it will start to send messages everytime the zone is updated:

{
  "SENDER":"XXX",
  "RECEIVER":"XXX",
  "CMD":"REALTIME",
  "ID":"0",
  "PAYLOAD_TYPE":"CHANGES",
  "PAYLOAD": {"XXX": {"STATUS_ZONES":     
    [{
      "ID":"2",
      "STA":"R",
      "BYP":"NO",
      "T":"N",
      "A":"N",
      "FM":"F",
      "OHM":"NA",
      "VAS":"F",
      "LBL":""}]
   }},
  "TIMESTAMP":"1736619675",
  "CRC_16":"0xE6C5")
}

so I think we should map this as well.

@tosomax I investigated a bit the PIN I use is not even Master I am using a Standard PIN from what I understand from JSONS. So there should be something different between ksenia lares4 and BTicino in terms of permissions.

@tosomax
Copy link

tosomax commented Jan 11, 2025

Let's start with mapping the zones. So what we need to figure out is what each of these fields mean and what values they have for the cases:

* Idle

* Bypassed

* Alarm/Triggerd

Extracted part:

{
  "ID": "1",
  "DES": "Porta Blindata",
  "PRT": "4",
  "CMD": "F",
  "BYP_EN": "T",
  "CAT": "GEN",
  "AN": "F"
}

Guess of chatgpt: Each zone in the "ZONES" array has the following properties: -ID: A unique identifier for the zone.

* `DES`: Description or name of the zone (e.g., "Porta Blindata" or "Garage").

* `PRT`: Port or partition number associated with the zone.

* `CMD`: Status or command for the zone. In this data, all zones have the value 'F', which might indicate "Fault" or "Inactive."

* `BYP_EN`: Bypass enabled status. 'T' indicates bypass is enabled for all zones.

* `CAT`: Category of the zone. All zones are of type 'GEN', which might mean "General."

* `AN`: An undefined flag, possibly for alarms. 'F' likely indicates "False" or "Disabled."

@tosomax @glsorre can you guys for 1 zone get exports when triggered (with alarm off) and when idle + maybe with bypass on and off?

I've tried to open the window from the "Studio" and disabled the zone "Porta blindata" from the app. The I've run the script and got this:
immagine
Unfortunately it doesn't seem to get the correct data.
Moreover, I wanted to activate some of the zones and test again. However, since I have to use the "installer PIN" to use the API, if I activate the alarm on any zone, then I cannot login anymore with the "installer PIN"

@tosomax
Copy link

tosomax commented Jan 11, 2025

I was able to do install a man in the middle proxy and get some very useful data:
now I have the commands sent from the app at the login and the responses.
Then I have tried to insert the alarm and get the response.
Then I have deactivated the alarm and got the response.

Finally I have opened one sensor and got a message from the 4200C, and then closed the sensor and got the update.

I have copied all the raw data, I will remove some info and post it here

@tosomax
Copy link

tosomax commented Jan 11, 2025

4200C.txt

@tosomax
Copy link

tosomax commented Jan 11, 2025

alarm_detected.txt
disarm_alarm.txt

@glsorre
Copy link
Author

glsorre commented Jan 12, 2025

OK...I will update the PR.

I will need to add a sort of auto discover feature as I have in lares4-ts the code will:

  1. initialize running the correct MULTI_TYPES messages and store statuses in memory
  2. register for updates and update in memory statuses
  3. reply to BaseApi calls from in memory status

Let's concentrate on alarm system for now...then I will add OUTPUTS and other domotic entities.
I hope I can work it during next week.

@tosomax
Copy link

tosomax commented Jan 12, 2025

OK...I will update the PR.

I will need to add a sort of auto discover feature as I have in lares4-ts the code will:

1. initialize running the correct MULTI_TYPES messages and store statuses in memory

2. register for updates and update in memory statuses

3. reply to BaseApi calls from in memory status

Let's concentrate on alarm system for now...then I will add OUTPUTS and other domotic entities. I hope I can work it during next week.

I was able to fix the login issue by changing this part (changed payload type from unknown to user)
I don't know if this works the same on ksenia though

async def send_login(self):
    login_command = self.command_factory.build_command(
        cmd="LOGIN",
        payload_type="USER",
        payload={
            "PIN": True
        }
    )

@glsorre
Copy link
Author

glsorre commented Jan 12, 2025

OK...I will update the PR.
I will need to add a sort of auto discover feature as I have in lares4-ts the code will:

1. initialize running the correct MULTI_TYPES messages and store statuses in memory

2. register for updates and update in memory statuses

3. reply to BaseApi calls from in memory status

Let's concentrate on alarm system for now...then I will add OUTPUTS and other domotic entities. I hope I can work it during next week.

I was able to fix the login issue by changing this part (changed payload type from unknown to user) I don't know if this works the same on ksenia though

async def send_login(self):
    login_command = self.command_factory.build_command(
        cmd="LOGIN",
        payload_type="USER",
        payload={
            "PIN": True
        }
    )

Cool! I used a similar approach to yours using a proxy to read messages sent and received to/from the socket by the web interface and the mobile app. For ksenia lares the login payload is:

{
            "SENDER":"XXX",
            "RECEIVER":"",            
            "CMD":"LOGIN",
            "ID": "1", 
            "PAYLOAD_TYPE":"UNKNOWN",
            "PAYLOAD":{"PIN":"XXX"},
            "TIMESTAMP":"1736673121",
            "CRC_16":"0xda5d"
 }

BTW is a small change we can manage it with some configuration injection.

@glsorre
Copy link
Author

glsorre commented Jan 12, 2025

Hi I had a couple of hours and I worked on it:

  • I half mapped and typed zones
  • I added a model parameter to the Lares4API class that will help to change behaviors between ksenia lares and BTicino 4200
  • I added methods to activate scenario and bypass zone

small step forward :)

@glsorre
Copy link
Author

glsorre commented Jan 12, 2025

OK...I will update the PR.

I will need to add a sort of auto discover feature as I have in lares4-ts the code will:

  1. initialize running the correct MULTI_TYPES messages and store statuses in memory
  2. register for updates and update in memory statuses
  3. reply to BaseApi calls from in memory status

Let's concentrate on alarm system for now...then I will add OUTPUTS and other domotic entities. I hope I can work it during next week.

not sure we need this anymore as I can ask directly for statuses in READ/MULTI_TYPES requests

@tosomax
Copy link

tosomax commented Jan 17, 2025

Found this, in case you find it useful
https://github.com/realnot16/kseniaWebsocketLibrary

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support Lares 4.0
3 participants