2023-02-16 06:06:42 +01:00
|
|
|
from twisted.internet.protocol import Factory, Protocol
|
|
|
|
import logging, coloredlogs
|
|
|
|
from Crypto.Cipher import AES
|
|
|
|
import struct
|
2023-08-14 05:32:03 +02:00
|
|
|
from typing import Dict, Tuple, Callable, Union
|
|
|
|
from typing_extensions import Final
|
2023-02-16 06:06:42 +01:00
|
|
|
from logging.handlers import TimedRotatingFileHandler
|
|
|
|
|
|
|
|
from core.config import CoreConfig
|
|
|
|
from core.data import Data
|
2023-08-14 05:32:03 +02:00
|
|
|
from .adb_handlers import *
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-02-16 06:06:42 +01:00
|
|
|
class AimedbProtocol(Protocol):
|
2023-08-14 05:32:03 +02:00
|
|
|
request_list: Dict[int, Tuple[Callable[[bytes, int], Union[ADBBaseResponse, bytes]], int, str]] = {}
|
2023-02-16 06:06:42 +01:00
|
|
|
|
|
|
|
def __init__(self, core_cfg: CoreConfig) -> None:
|
|
|
|
self.logger = logging.getLogger("aimedb")
|
|
|
|
self.config = core_cfg
|
|
|
|
self.data = Data(core_cfg)
|
|
|
|
if core_cfg.aimedb.key == "":
|
|
|
|
self.logger.error("!!!KEY NOT SET!!!")
|
|
|
|
exit(1)
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
self.register_handler(0x01, 0x03, self.handle_felica_lookup, 'felica_lookup')
|
|
|
|
self.register_handler(0x02, 0x03, self.handle_felica_register, 'felica_register')
|
|
|
|
|
|
|
|
self.register_handler(0x04, 0x06, self.handle_lookup, 'lookup')
|
|
|
|
self.register_handler(0x05, 0x06, self.handle_register, 'register')
|
|
|
|
|
|
|
|
self.register_handler(0x07, 0x08, self.handle_status_log, 'status_log')
|
|
|
|
self.register_handler(0x09, 0x0A, self.handle_log, 'aime_log')
|
|
|
|
|
|
|
|
self.register_handler(0x0B, 0x0C, self.handle_campaign, 'campaign')
|
|
|
|
self.register_handler(0x0D, 0x0E, self.handle_campaign_clear, 'campaign_clear')
|
|
|
|
|
|
|
|
self.register_handler(0x0F, 0x10, self.handle_lookup_ex, 'lookup_ex')
|
|
|
|
self.register_handler(0x11, 0x12, self.handle_felica_lookup_ex, 'felica_lookup_ex')
|
|
|
|
|
|
|
|
self.register_handler(0x13, 0x14, self.handle_log_ex, 'aime_log_ex')
|
|
|
|
self.register_handler(0x64, 0x65, self.handle_hello, 'hello')
|
|
|
|
self.register_handler(0x66, 0, self.handle_goodbye, 'goodbye')
|
|
|
|
|
|
|
|
def register_handler(self, cmd: int, resp:int, handler: Callable[[bytes, int], Union[ADBBaseResponse, bytes]], name: str) -> None:
|
|
|
|
self.request_list[cmd] = (handler, resp, name)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
|
|
|
def append_padding(self, data: bytes):
|
|
|
|
"""Appends 0s to the end of the data until it's at the correct size"""
|
|
|
|
length = struct.unpack_from("<H", data, 6)
|
|
|
|
padding_size = length[0] - len(data)
|
|
|
|
data += bytes(padding_size)
|
|
|
|
return data
|
|
|
|
|
|
|
|
def connectionMade(self) -> None:
|
|
|
|
self.logger.debug(f"{self.transport.getPeer().host} Connected")
|
|
|
|
|
|
|
|
def connectionLost(self, reason) -> None:
|
2023-03-09 17:38:58 +01:00
|
|
|
self.logger.debug(
|
|
|
|
f"{self.transport.getPeer().host} Disconnected - {reason.value}"
|
|
|
|
)
|
|
|
|
|
2023-02-16 06:06:42 +01:00
|
|
|
def dataReceived(self, data: bytes) -> None:
|
|
|
|
cipher = AES.new(self.config.aimedb.key.encode(), AES.MODE_ECB)
|
|
|
|
|
|
|
|
try:
|
|
|
|
decrypted = cipher.decrypt(data)
|
2023-08-14 05:32:03 +02:00
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
self.logger.error(f"Failed to decrypt {data.hex()} because {e}")
|
2023-02-16 06:06:42 +01:00
|
|
|
return None
|
|
|
|
|
|
|
|
self.logger.debug(f"{self.transport.getPeer().host} wrote {decrypted.hex()}")
|
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
try:
|
|
|
|
head = ADBHeader.from_data(decrypted)
|
|
|
|
|
|
|
|
except ADBHeaderException as e:
|
|
|
|
self.logger.error(f"Error parsing ADB header: {e}")
|
|
|
|
try:
|
|
|
|
encrypted = cipher.encrypt(ADBBaseResponse().make())
|
|
|
|
self.transport.write(encrypted)
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
self.logger.error(f"Failed to encrypt default response because {e}")
|
|
|
|
|
|
|
|
return
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-28 01:18:22 +02:00
|
|
|
if head.keychip_id == "ABCD1234567" or head.store_id == 0xfff0:
|
|
|
|
self.logger.warning(f"Request from uninitialized AMLib: {vars(head)}")
|
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
handler, resp_code, name = self.request_list.get(head.cmd, (self.handle_default, None, 'default'))
|
|
|
|
|
|
|
|
if resp_code is None:
|
|
|
|
self.logger.warning(f"No handler for cmd {hex(head.cmd)}")
|
|
|
|
|
|
|
|
elif resp_code > 0:
|
|
|
|
self.logger.info(f"{name} from {head.keychip_id} ({head.game_id}) @ {self.transport.getPeer().host}")
|
|
|
|
|
|
|
|
resp = handler(decrypted, resp_code)
|
|
|
|
|
|
|
|
if type(resp) == ADBBaseResponse or issubclass(type(resp), ADBBaseResponse):
|
|
|
|
resp_bytes = resp.make()
|
|
|
|
if len(resp_bytes) != resp.head.length:
|
|
|
|
resp_bytes = self.append_padding(resp_bytes)
|
|
|
|
|
|
|
|
elif type(resp) == bytes:
|
|
|
|
resp_bytes = resp
|
|
|
|
|
|
|
|
elif resp is None: # Nothing to send, probably a goodbye
|
2023-02-16 06:06:42 +01:00
|
|
|
return
|
2023-08-14 05:32:03 +02:00
|
|
|
|
|
|
|
else:
|
|
|
|
raise TypeError(f"Unsupported type returned by ADB handler for {name}: {type(resp)}")
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
try:
|
|
|
|
encrypted = cipher.encrypt(resp_bytes)
|
|
|
|
self.logger.debug(f"Response {resp_bytes.hex()}")
|
2023-02-16 06:06:42 +01:00
|
|
|
self.transport.write(encrypted)
|
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
except Exception as e:
|
|
|
|
self.logger.error(f"Failed to encrypt {resp_bytes.hex()} because {e}")
|
|
|
|
|
|
|
|
def handle_default(self, data: bytes, resp_code: int, length: int = 0x20) -> ADBBaseResponse:
|
|
|
|
req = ADBHeader.from_data(data)
|
2023-08-19 07:35:37 +02:00
|
|
|
return ADBBaseResponse(resp_code, length, 1, req.game_id, req.store_id, req.keychip_id, req.protocol_ver)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
def handle_hello(self, data: bytes, resp_code: int) -> ADBBaseResponse:
|
|
|
|
return self.handle_default(data, resp_code)
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
def handle_campaign(self, data: bytes, resp_code: int) -> ADBBaseResponse:
|
|
|
|
h = ADBHeader.from_data(data)
|
|
|
|
if h.protocol_ver >= 0x3030:
|
|
|
|
req = h
|
2023-08-19 07:35:37 +02:00
|
|
|
resp = ADBCampaignResponse.from_req(req)
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
else:
|
|
|
|
req = ADBOldCampaignRequest(data)
|
|
|
|
|
|
|
|
self.logger.info(f"Legacy campaign request for campaign {req.campaign_id} (protocol version {hex(h.protocol_ver)})")
|
2023-08-19 07:35:37 +02:00
|
|
|
resp = ADBOldCampaignResponse.from_req(req.head)
|
2023-08-14 05:32:03 +02:00
|
|
|
|
|
|
|
# We don't currently support campaigns
|
|
|
|
return resp
|
|
|
|
|
|
|
|
def handle_lookup(self, data: bytes, resp_code: int) -> ADBBaseResponse:
|
|
|
|
req = ADBLookupRequest(data)
|
|
|
|
user_id = self.data.card.get_user_id_from_card(req.access_code)
|
2023-10-20 05:25:12 +02:00
|
|
|
is_banned = self.data.card.get_card_banned(req.access_code)
|
|
|
|
is_locked = self.data.card.get_card_locked(req.access_code)
|
|
|
|
|
|
|
|
if is_banned and is_locked:
|
|
|
|
ret.head.status = ADBStatus.BAN_SYS_USER
|
|
|
|
elif is_banned:
|
|
|
|
ret.head.status = ADBStatus.BAN_SYS
|
|
|
|
elif is_locked:
|
|
|
|
ret.head.status = ADBStatus.LOCK_USER
|
2023-08-19 07:35:37 +02:00
|
|
|
ret = ADBLookupResponse.from_req(req.head, user_id)
|
2023-08-14 05:32:03 +02:00
|
|
|
|
|
|
|
self.logger.info(
|
|
|
|
f"access_code {req.access_code} -> user_id {ret.user_id}"
|
2023-03-09 17:38:58 +01:00
|
|
|
)
|
2023-08-14 05:32:03 +02:00
|
|
|
return ret
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
def handle_lookup_ex(self, data: bytes, resp_code: int) -> ADBBaseResponse:
|
|
|
|
req = ADBLookupRequest(data)
|
|
|
|
user_id = self.data.card.get_user_id_from_card(req.access_code)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-10-20 05:25:12 +02:00
|
|
|
is_banned = self.data.card.get_card_banned(req.access_code)
|
|
|
|
is_locked = self.data.card.get_card_locked(req.access_code)
|
|
|
|
|
2023-08-19 07:35:37 +02:00
|
|
|
ret = ADBLookupExResponse.from_req(req.head, user_id)
|
2023-10-20 05:25:12 +02:00
|
|
|
if is_banned and is_locked:
|
|
|
|
ret.head.status = ADBStatus.BAN_SYS_USER
|
|
|
|
elif is_banned:
|
|
|
|
ret.head.status = ADBStatus.BAN_SYS
|
|
|
|
elif is_locked:
|
|
|
|
ret.head.status = ADBStatus.LOCK_USER
|
|
|
|
|
2023-03-09 17:38:58 +01:00
|
|
|
self.logger.info(
|
2023-08-14 05:32:03 +02:00
|
|
|
f"access_code {req.access_code} -> user_id {ret.user_id}"
|
2023-03-09 17:38:58 +01:00
|
|
|
)
|
2023-08-14 05:32:03 +02:00
|
|
|
return ret
|
|
|
|
|
|
|
|
def handle_felica_lookup(self, data: bytes, resp_code: int) -> bytes:
|
|
|
|
"""
|
|
|
|
On official, I think a card has to be registered for this to actually work, but
|
|
|
|
I'm making the executive decision to not implement that and just kick back our
|
|
|
|
faux generated access code. The real felica IDm -> access code conversion is done
|
|
|
|
on the ADB server, which we do not and will not ever have access to. Because we can
|
|
|
|
assure that all IDms will be unique, this basic 0-padded hex -> int conversion will
|
|
|
|
be fine.
|
|
|
|
"""
|
|
|
|
req = ADBFelicaLookupRequest(data)
|
|
|
|
ac = self.data.card.to_access_code(req.idm)
|
|
|
|
self.logger.info(
|
|
|
|
f"idm {req.idm} ipm {req.pmm} -> access_code {ac}"
|
2023-03-09 17:38:58 +01:00
|
|
|
)
|
2023-08-19 07:35:37 +02:00
|
|
|
return ADBFelicaLookupResponse.from_req(req.head, ac)
|
2023-08-14 05:32:03 +02:00
|
|
|
|
|
|
|
def handle_felica_register(self, data: bytes, resp_code: int) -> bytes:
|
|
|
|
"""
|
|
|
|
I've never seen this used.
|
|
|
|
"""
|
|
|
|
req = ADBFelicaLookupRequest(data)
|
|
|
|
ac = self.data.card.to_access_code(req.idm)
|
|
|
|
|
|
|
|
if self.config.server.allow_user_registration:
|
|
|
|
user_id = self.data.user.create_user()
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
if user_id is None:
|
|
|
|
self.logger.error("Failed to register user!")
|
|
|
|
user_id = -1
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
else:
|
|
|
|
card_id = self.data.card.create_card(user_id, ac)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
if card_id is None:
|
|
|
|
self.logger.error("Failed to register card!")
|
|
|
|
user_id = -1
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
self.logger.info(
|
|
|
|
f"Register access code {ac} (IDm: {req.idm} PMm: {req.pmm}) -> user_id {user_id}"
|
|
|
|
)
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
else:
|
|
|
|
self.logger.info(
|
|
|
|
f"Registration blocked!: access code {ac} (IDm: {req.idm} PMm: {req.pmm})"
|
|
|
|
)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-19 07:35:37 +02:00
|
|
|
return ADBFelicaLookupResponse.from_req(req.head, ac)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
def handle_felica_lookup_ex(self, data: bytes, resp_code: int) -> bytes:
|
|
|
|
req = ADBFelicaLookup2Request(data)
|
|
|
|
access_code = self.data.card.to_access_code(req.idm)
|
2023-02-16 06:06:42 +01:00
|
|
|
user_id = self.data.card.get_user_id_from_card(access_code=access_code)
|
|
|
|
|
2023-03-09 17:38:58 +01:00
|
|
|
if user_id is None:
|
|
|
|
user_id = -1
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-03-09 17:38:58 +01:00
|
|
|
self.logger.info(
|
2023-08-14 05:32:03 +02:00
|
|
|
f"idm {req.idm} ipm {req.pmm} -> access_code {access_code} user_id {user_id}"
|
2023-03-09 17:38:58 +01:00
|
|
|
)
|
|
|
|
|
2023-08-19 07:35:37 +02:00
|
|
|
return ADBFelicaLookup2Response.from_req(req.head, user_id, access_code)
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
def handle_campaign_clear(self, data: bytes, resp_code: int) -> ADBBaseResponse:
|
|
|
|
req = ADBCampaignClearRequest(data)
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-08-19 07:35:37 +02:00
|
|
|
resp = ADBCampaignClearResponse.from_req(req.head)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
# We don't support campaign stuff
|
|
|
|
return resp
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
def handle_register(self, data: bytes, resp_code: int) -> bytes:
|
|
|
|
req = ADBLookupRequest(data)
|
|
|
|
user_id = -1
|
2023-10-20 05:25:12 +02:00
|
|
|
|
2023-02-19 06:07:14 +01:00
|
|
|
if self.config.server.allow_user_registration:
|
2023-02-16 06:06:42 +01:00
|
|
|
user_id = self.data.user.create_user()
|
|
|
|
|
2023-03-09 17:38:58 +01:00
|
|
|
if user_id is None:
|
2023-02-16 06:06:42 +01:00
|
|
|
self.logger.error("Failed to register user!")
|
2023-08-14 05:32:03 +02:00
|
|
|
user_id = -1
|
2023-02-16 06:06:42 +01:00
|
|
|
|
|
|
|
else:
|
2023-08-14 05:32:03 +02:00
|
|
|
card_id = self.data.card.create_card(user_id, req.access_code)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-03-09 17:38:58 +01:00
|
|
|
if card_id is None:
|
2023-02-16 06:06:42 +01:00
|
|
|
self.logger.error("Failed to register card!")
|
2023-08-14 05:32:03 +02:00
|
|
|
user_id = -1
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-03-09 17:38:58 +01:00
|
|
|
self.logger.info(
|
2023-08-14 05:32:03 +02:00
|
|
|
f"Register access code {req.access_code} -> user_id {user_id}"
|
2023-03-09 17:38:58 +01:00
|
|
|
)
|
|
|
|
|
2023-02-16 06:06:42 +01:00
|
|
|
else:
|
2023-03-09 17:38:58 +01:00
|
|
|
self.logger.info(
|
2023-08-14 05:32:03 +02:00
|
|
|
f"Registration blocked!: access code {req.access_code}"
|
2023-03-09 17:38:58 +01:00
|
|
|
)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-19 07:35:37 +02:00
|
|
|
resp = ADBLookupResponse.from_req(req.head, user_id)
|
2023-08-14 05:32:03 +02:00
|
|
|
if resp.user_id <= 0:
|
|
|
|
resp.head.status = ADBStatus.BAN_SYS # Closest we can get to a "You cannot register"
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
return resp
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
# TODO: Save these in some capacity, as deemed relevant
|
|
|
|
def handle_status_log(self, data: bytes, resp_code: int) -> bytes:
|
|
|
|
req = ADBStatusLogRequest(data)
|
|
|
|
self.logger.info(f"User {req.aime_id} logged {req.status.name} event")
|
2023-08-19 07:35:37 +02:00
|
|
|
return ADBBaseResponse(resp_code, 0x20, 1, req.head.game_id, req.head.store_id, req.head.keychip_id, req.head.protocol_ver)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
def handle_log(self, data: bytes, resp_code: int) -> bytes:
|
|
|
|
req = ADBLogRequest(data)
|
|
|
|
self.logger.info(f"User {req.aime_id} logged {req.status.name} event, credit_ct: {req.credit_ct} bet_ct: {req.bet_ct} won_ct: {req.won_ct}")
|
2023-08-19 07:35:37 +02:00
|
|
|
return ADBBaseResponse(resp_code, 0x20, 1, req.head.game_id, req.head.store_id, req.head.keychip_id, req.head.protocol_ver)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
def handle_log_ex(self, data: bytes, resp_code: int) -> bytes:
|
|
|
|
req = ADBLogExRequest(data)
|
2023-08-21 01:55:26 +02:00
|
|
|
strs = []
|
|
|
|
self.logger.info(f"Recieved {req.num_logs} or {len(req.logs)} logs")
|
|
|
|
|
|
|
|
for x in range(req.num_logs):
|
|
|
|
self.logger.debug(f"User {req.logs[x].aime_id} logged {req.logs[x].status.name} event, credit_ct: {req.logs[x].credit_ct} bet_ct: {req.logs[x].bet_ct} won_ct: {req.logs[x].won_ct}")
|
2023-08-21 01:56:16 +02:00
|
|
|
return ADBLogExResponse.from_req(req.head)
|
2023-02-16 06:06:42 +01:00
|
|
|
|
2023-08-14 05:32:03 +02:00
|
|
|
def handle_goodbye(self, data: bytes, resp_code: int) -> None:
|
|
|
|
self.logger.info(f"goodbye from {self.transport.getPeer().host}")
|
|
|
|
self.transport.loseConnection()
|
|
|
|
return
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-02-16 06:06:42 +01:00
|
|
|
class AimedbFactory(Factory):
|
|
|
|
protocol = AimedbProtocol
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-02-16 06:06:42 +01:00
|
|
|
def __init__(self, cfg: CoreConfig) -> None:
|
|
|
|
self.config = cfg
|
|
|
|
log_fmt_str = "[%(asctime)s] Aimedb | %(levelname)s | %(message)s"
|
|
|
|
log_fmt = logging.Formatter(log_fmt_str)
|
|
|
|
self.logger = logging.getLogger("aimedb")
|
|
|
|
|
2023-03-09 17:38:58 +01:00
|
|
|
fileHandler = TimedRotatingFileHandler(
|
|
|
|
"{0}/{1}.log".format(self.config.server.log_dir, "aimedb"),
|
|
|
|
when="d",
|
|
|
|
backupCount=10,
|
|
|
|
)
|
2023-02-16 06:06:42 +01:00
|
|
|
fileHandler.setFormatter(log_fmt)
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-02-16 06:06:42 +01:00
|
|
|
consoleHandler = logging.StreamHandler()
|
|
|
|
consoleHandler.setFormatter(log_fmt)
|
|
|
|
|
|
|
|
self.logger.addHandler(fileHandler)
|
|
|
|
self.logger.addHandler(consoleHandler)
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-02-16 06:06:42 +01:00
|
|
|
self.logger.setLevel(self.config.aimedb.loglevel)
|
2023-03-09 17:38:58 +01:00
|
|
|
coloredlogs.install(
|
|
|
|
level=cfg.aimedb.loglevel, logger=self.logger, fmt=log_fmt_str
|
|
|
|
)
|
|
|
|
|
2023-02-16 06:06:42 +01:00
|
|
|
if self.config.aimedb.key == "":
|
|
|
|
self.logger.error("Please set 'key' field in your config file.")
|
|
|
|
exit(1)
|
|
|
|
|
|
|
|
self.logger.info(f"Ready on port {self.config.aimedb.port}")
|
2023-03-09 17:38:58 +01:00
|
|
|
|
2023-02-16 06:06:42 +01:00
|
|
|
def buildProtocol(self, addr):
|
|
|
|
return AimedbProtocol(self.config)
|