diff --git a/configs/config_app_example.toml b/configs/config_app_example.toml index 81c4a476..84c8a813 100644 --- a/configs/config_app_example.toml +++ b/configs/config_app_example.toml @@ -1,5 +1,7 @@ # MAKE SURE TO RENAME THIS FILE TO config.toml AND PLACE IT IN THE ROOT OF THE PROJECT +node_name = "example_node" + # Router configuration router_name = "router1" router_address = "xx.xx.xx.xx" # Replace with actual IP address diff --git a/src/pqnstack/app/api/deps.py b/src/pqnstack/app/api/deps.py index e3c163b5..079886a0 100644 --- a/src/pqnstack/app/api/deps.py +++ b/src/pqnstack/app/api/deps.py @@ -6,7 +6,8 @@ from pqnstack.app.core.config import settings from pqnstack.network.client import Client - +from pqnstack.app.core.config import NodeState +from pqnstack.app.core.config import get_state async def get_http_client() -> AsyncGenerator[httpx.AsyncClient, None]: async with httpx.AsyncClient(timeout=60) as client: @@ -22,3 +23,6 @@ async def get_instrument_client() -> AsyncGenerator[Client, None]: InstrumentClientDep = Annotated[httpx.AsyncClient, Depends(get_instrument_client)] + + +StateDep = Annotated[NodeState, Depends(get_state)] diff --git a/src/pqnstack/app/api/main.py b/src/pqnstack/app/api/main.py index d50d7c12..175e0899 100644 --- a/src/pqnstack/app/api/main.py +++ b/src/pqnstack/app/api/main.py @@ -1,6 +1,8 @@ from fastapi import APIRouter from pqnstack.app.api.routes import chsh +from pqnstack.app.api.routes import coordination +from pqnstack.app.api.routes import debug from pqnstack.app.api.routes import qkd from pqnstack.app.api.routes import rng from pqnstack.app.api.routes import serial @@ -12,3 +14,5 @@ api_router.include_router(timetagger.router) api_router.include_router(rng.router) api_router.include_router(serial.router) +api_router.include_router(coordination.router) +api_router.include_router(debug.router) diff --git a/src/pqnstack/app/api/routes/chsh.py b/src/pqnstack/app/api/routes/chsh.py index c1bd7d7c..6a66989e 100644 --- a/src/pqnstack/app/api/routes/chsh.py +++ b/src/pqnstack/app/api/routes/chsh.py @@ -7,8 +7,8 @@ from fastapi import status from pqnstack.app.api.deps import ClientDep +from pqnstack.app.api.deps import StateDep from pqnstack.app.core.config import settings -from pqnstack.app.core.config import state from pqnstack.app.core.models import calculate_chsh_expectation_error from pqnstack.network.client import Client @@ -44,6 +44,7 @@ async def _chsh( # Complexity is high due to the nature of the CHSH experiment. expectation_values = [] expectation_errors = [] + basis = [0, abs(basis[0] - basis[1]) % 90] for angle in basis: # Going through my basis angles for i in range(2): # Going through follower basis angles counts = [] @@ -94,18 +95,12 @@ async def _chsh( # Complexity is high due to the nature of the CHSH experiment. logger.info("Expectation values: %s", expectation_values) logger.info("Expectation errors: %s", expectation_errors) - negative_count = sum(1 for v in expectation_values if v < 0) - negative_indices = [i for i, v in enumerate(expectation_values) if v < 0] - impossible_counts = [0, 2, 4] + # FIXME: This is a temporary fix for handling impossible expectation values. We should not have to rely on the settings for this. + expectation_values = [x*y for x,y in zip(expectation_values, settings.chsh_settings.expectation_signs)] + logger.info("What are you settings? %s", settings.chsh_settings.expectation_signs) - if negative_count in impossible_counts: - msg = f"Impossible negative expectation values found: {negative_indices}, expectation_values = {expectation_values}, expectation_errors = {expectation_errors}" - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=msg) - - if len(negative_indices) > 1 or negative_indices[0] != 0: - logger.warning("Expectation values have unexpected negative indices: %s", negative_indices) - - chsh_value = sum(abs(x) for x in expectation_values) + logger.info("After passing signed calculation: %s", expectation_values) + chsh_value = sum(x for x in expectation_values) chsh_error = sum(x**2 for x in expectation_errors) ** 0.5 return chsh_value, chsh_error @@ -129,7 +124,7 @@ async def chsh( @router.post("/request-angle-by-basis") -async def request_angle_by_basis(index: int, *, perp: bool = False) -> bool: +async def request_angle_by_basis(index: int, state: StateDep, *, perp: bool = False) -> bool: client = Client(host=settings.router_address, port=settings.router_port, timeout=600_000) hwp = cast( "RotatorInstrument", diff --git a/src/pqnstack/app/api/routes/coordination.py b/src/pqnstack/app/api/routes/coordination.py new file mode 100644 index 00000000..e7e6dc64 --- /dev/null +++ b/src/pqnstack/app/api/routes/coordination.py @@ -0,0 +1,199 @@ +import asyncio +import logging + +from fastapi import APIRouter +from fastapi import HTTPException +from fastapi import Request +from fastapi import WebSocket +from fastapi import WebSocketDisconnect +from fastapi import status +from pydantic import BaseModel + +from pqnstack.app.api.deps import ClientDep +from pqnstack.app.api.deps import StateDep +from pqnstack.app.core.config import ask_user_for_follow_event +from pqnstack.app.core.config import settings +from pqnstack.app.core.config import user_replied_event + +logger = logging.getLogger(__name__) + + +class FollowRequestResponse(BaseModel): + accepted: bool + + +class CollectFollowerResponse(BaseModel): + accepted: bool + + +class ResetCoordinationStateResponse(BaseModel): + message: str = "Coordination state reset successfully" + + +router = APIRouter(prefix="/coordination", tags=["coordination"]) + + +# TODO: Send a disconnection message if I was following/leading someone. +# FIXME: This is techincally resetting more than just coordination state. including qkd. +@router.post("/reset_coordination_state") +async def reset_coordination_state(state: StateDep) -> ResetCoordinationStateResponse: + """Reset the coordination state of the node.""" + state.leading = False + state.followers_address = "" + state.following = False + state.following_requested = False + state.following_requested_user_response = None + state.leaders_address = "" + state.leaders_name = "" + state.qkd_emoji_pick = "" + state.qkd_bit_list = [] + state.qkd_question_order = [] + state.qkd_leader_basis_list = [] + state.qkd_follower_basis_list = [] + state.qkd_single_bit_current_index = 0 + state.qkd_resulting_bit_list = [] + state.qkd_request_basis_list = [] + state.qkd_request_bit_list = [] + state.qkd_n_matching_bits = -1 + return ResetCoordinationStateResponse() + + +@router.post("/collect_follower") +async def collect_follower( + request: Request, address: str, state: StateDep, http_client: ClientDep +) -> CollectFollowerResponse: + """ + Endpoint called by a leader node (this one) to request a follower node (other node) to follow it. + + Returns + ------- + CollectFollowerResponse indicating if the follower accepted the request. + """ + logger.info("Requesting client at %s to follow", address) + + # Get the port this server is listening on + server_port = request.scope["server"][1] + + ret = await http_client.post( + f"http://{address}/coordination/follow_requested?leaders_name={settings.node_name}&leaders_port={server_port}" + ) + if ret.status_code != status.HTTP_200_OK: + raise HTTPException(status_code=ret.status_code, detail=ret.text) + + response_data = ret.json() + if response_data.get("accepted") is True: + state.leading = True + state.followers_address = address + logger.info("Successfully collected follower") + return CollectFollowerResponse(accepted=True) + if response_data.get("accepted") is False: + logger.info("Follower rejected follow request") + return CollectFollowerResponse(accepted=False) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not collect follower for unknown reasons" + ) + + +@router.post("/follow_requested") +async def follow_requested( + request: Request, leaders_name: str, leaders_port: int, state: StateDep +) -> FollowRequestResponse: + """ + Endpoint is called by a leader node (other node) to request this node to follow it. + + Returns + ------- + FollowRequestResponse indicating if the follow request is accepted. + """ + logger.debug("Requesting client at %s to follow", leaders_name) + + if request.client is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Request lacks the clients host") + leaders_address = f"{request.client.host}:{leaders_port}" + + # Check if the client is ready to accept a follower request and that node is not already following someone. + if not state.client_listening_for_follower_requests or state.following: + logger.info( + "Request rejected because %s", + ( + "client is not listening for requests" + if not state.client_listening_for_follower_requests + else "this node is already following someone" + ), + ) + return FollowRequestResponse(accepted=False) + + state.following_requested = True + state.leaders_name = leaders_name + state.leaders_address = leaders_address + # Trigger the state change to get the websocket to send question to user + ask_user_for_follow_event.set() + + logger.debug("Asking user to accept follow request from %s (%s)", leaders_name, leaders_address) + await user_replied_event.wait() # Wait for a state change event to see if user accepted + user_replied_event.clear() # Reset the event for the next change + if state.following_requested_user_response: + logger.debug("Follow request from %s accepted.", leaders_address) + state.following = True + state.leaders_name = leaders_name + state.leaders_address = leaders_address + return FollowRequestResponse(accepted=True) + + logger.debug("Follow request from %s rejected.", leaders_address) + # Clean up the state if user rejected + state.leaders_address = "" + state.leaders_name = "" + state.following_requested = False + state.following_requested_user_response = None + return FollowRequestResponse(accepted=False) + + +@router.websocket("/follow_requested_alerts") +async def follow_requested_alert(websocket: WebSocket, state: StateDep) -> None: + """Websocket endpoint is used to alert the client when a follow request is received. It also handles the response from the client.""" + await websocket.accept() + logger.info("Client connected to websocket for multiplayer coordination.") + state.client_listening_for_follower_requests = True + + async def ask_user_for_follow_handler() -> None: + """Task that waits for the ask_user_for_follow_event event and sends a message to the client if a follow request is detected.""" + while True: + try: + await ask_user_for_follow_event.wait() # Wait for a state change event + if state.following_requested: + logger.debug("Websocket detected a follow request, asking user for response.") + if websocket.client_state.name == "CONNECTED": + await websocket.send_text(f"Do you want to accept a connection from {state.leaders_name}?") + else: + logger.debug("WebSocket not connected, cannot send message") + break + ask_user_for_follow_event.clear() # Reset the event for the next change + except WebSocketDisconnect: + logger.info("WebSocket disconnected in ask_user_for_follow_handler") + break + except Exception: + logger.exception("Error in ask_user_for_follow_handler, continuing to listen") + ask_user_for_follow_event.clear() # Reset the event to continue + + async def client_message_handler() -> None: + """Task that waits for a message from the client and handles the response. It also handles the case where the client disconnects.""" + try: + while True: + response = await websocket.receive_text() + state.following_requested_user_response = response.lower() in ["true", "yes", "y"] + state.following_requested = False + logger.debug("Websocket received a response from user: %s", state.following_requested_user_response) + user_replied_event.set() + except WebSocketDisconnect: + logger.info("Client disconnected from websocket for multiplayer coordination.") + state.client_listening_for_follower_requests = False + + state_change_task = asyncio.create_task(ask_user_for_follow_handler()) + client_message_task = asyncio.create_task(client_message_handler()) + + try: + await asyncio.gather(state_change_task, client_message_task) + finally: + state_change_task.cancel() + client_message_task.cancel() diff --git a/src/pqnstack/app/api/routes/debug.py b/src/pqnstack/app/api/routes/debug.py new file mode 100644 index 00000000..3e43d74b --- /dev/null +++ b/src/pqnstack/app/api/routes/debug.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from pqnstack.app.api.deps import StateDep +from pqnstack.app.core.config import NodeState +from pqnstack.app.core.config import Settings +from pqnstack.app.core.config import settings + +router = APIRouter(prefix="/debug", tags=["debug"]) + + +@router.get("/state") +async def get_state(state: StateDep) -> NodeState: + return state + + +@router.get("/settings") +async def get_settings() -> Settings: + return settings diff --git a/src/pqnstack/app/api/routes/qkd.py b/src/pqnstack/app/api/routes/qkd.py index ab32c1e5..33547d60 100644 --- a/src/pqnstack/app/api/routes/qkd.py +++ b/src/pqnstack/app/api/routes/qkd.py @@ -1,15 +1,21 @@ +import asyncio import logging import secrets +import random from typing import TYPE_CHECKING from typing import cast +import httpx from fastapi import APIRouter from fastapi import HTTPException from fastapi import status +from pydantic import BaseModel from pqnstack.app.api.deps import ClientDep +from pqnstack.app.api.deps import StateDep +from pqnstack.app.core.config import NodeState +from pqnstack.app.core.config import qkd_result_received_event from pqnstack.app.core.config import settings -from pqnstack.app.core.config import state from pqnstack.constants import BasisBool from pqnstack.constants import QKDEncodingBasis from pqnstack.network.client import Client @@ -22,9 +28,17 @@ router = APIRouter(prefix="/qkd", tags=["qkd"]) +class QKDResult(BaseModel): + n_matching_bits: int + n_total_bits: int + emoji: str + role: str + + async def _qkd( follower_node_address: str, http_client: ClientDep, + state: StateDep, timetagger_address: str | None = None, ) -> list[int]: logger.debug("Starting QKD") @@ -39,7 +53,7 @@ async def _qkd( ) counts = [] - for basis in state.qkd_basis_list: + for basis in state.qkd_leader_basis_list: r = await http_client.post(f"http://{follower_node_address}/qkd/single_bit") if r.status_code != status.HTTP_200_OK: @@ -76,16 +90,19 @@ def get_outcome(state: int, basis: int, choice: int, counts: int) -> int: outcome = [] logger.debug( - "Going for qkd_basis_list: %s, qkd_bit_list: %s, counts: %s", state.qkd_basis_list, state.qkd_bit_list, counts + "Going for qkd_leader_basis_list: %s, qkd_bit_list: %s, counts: %s", + state.qkd_leader_basis_list, + state.qkd_bit_list, + counts, ) - for basis, choice, count in zip(state.qkd_basis_list, state.qkd_bit_list, counts, strict=False): + for basis, choice, count in zip(state.qkd_leader_basis_list, state.qkd_bit_list, counts, strict=False): out = get_outcome(settings.bell_state.value, BasisBool[basis.name].value, choice, count) logger.debug( "Calculating outcome for basis: %s, choice: %s, count: %s, outcome: %s", basis.name, choice, count, out ) outcome.append(out) - basis_list = [basis.name for basis in state.qkd_basis_list] + basis_list = [basis.name for basis in state.qkd_leader_basis_list] # FIXME: Send already binary basis instead of HV/AD. r = await http_client.post(f"http://{follower_node_address}/qkd/request_basis_list", json=basis_list) @@ -109,21 +126,22 @@ def get_outcome(state: int, basis: int, choice: int, counts: int) -> int: async def qkd( follower_node_address: str, http_client: ClientDep, + state: StateDep, timetagger_address: str | None = None, ) -> list[int]: """Perform a QKD protocol with the given follower node.""" - if not state.qkd_basis_list: + if not state.qkd_leader_basis_list: logger.error("QKD basis list is empty") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="QKD basis list is empty", ) - return await _qkd(follower_node_address, http_client, timetagger_address) + return await _qkd(follower_node_address, http_client, state, timetagger_address) @router.post("/single_bit") -async def request_qkd_single_pass() -> bool: +async def request_qkd_single_pass(state: StateDep) -> bool: client = Client(host=settings.router_address, port=settings.router_port, timeout=600_000) hwp = cast( "RotatorInstrument", @@ -139,8 +157,18 @@ async def request_qkd_single_pass() -> bool: logger.debug("Halfwaveplate device found: %s", hwp) - _bases = (QKDEncodingBasis.HV, QKDEncodingBasis.DA) - basis_choice = _bases[secrets.randbits(1)] # FIXME: Make this real quantum random. + # Check if we have basis choices available + if state.qkd_single_bit_current_index >= len(state.qkd_follower_basis_list): + logger.error("No more basis choices available in follower basis list") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No more basis choices available in follower basis list", + ) + + # Get the basis choice from the follower basis list + basis_choice = state.qkd_follower_basis_list[state.qkd_single_bit_current_index] + state.qkd_single_bit_current_index += 1 + int_choice = secrets.randbits(1) # FIXME: Make this real quantum random. state.qkd_request_basis_list.append(basis_choice) @@ -153,7 +181,7 @@ async def request_qkd_single_pass() -> bool: @router.post("/request_basis_list") -def request_qkd_basis_list(leader_basis_list: list[str]) -> list[str]: +def request_qkd_basis_list(leader_basis_list: list[str], state: StateDep) -> list[str]: """Return the list of basis angles for QKD.""" # Check that lengths match if len(leader_basis_list) != len(state.qkd_request_basis_list): @@ -172,3 +200,231 @@ def request_qkd_basis_list(leader_basis_list: list[str]) -> list[str]: state.qkd_request_bit_list.clear() return ret + + +@router.post("/set_qkd_emoji") +def set_qkd_emoji(emoji: str, state: StateDep) -> None: + """Set the emoji pick for QKD.""" + state.qkd_emoji_pick = emoji + + +@router.get("/question_order") +async def request_qkd_question_order( + state: StateDep, + http_client: ClientDep, +) -> list[int]: + """ + Return the question order for QKD. + + If this node is a leader, it generates a random question order and stores it in the state. + If this node is a follower, it requests the question order from the leader node. + Returns the question order as a list of integers. + """ + if state.leading and state.following: + logger.error("Node cannot be both leader and follower") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Node cannot be both leader and follower", + ) + + if len(state.qkd_question_order) == 0: + if state.leading and state.followers_address != "": + question_range = range( + settings.qkd_settings.minimum_question_index, settings.qkd_settings.maximum_question_index + 1 + ) + question_order = random.sample(list(question_range), settings.qkd_settings.bitstring_length) # just choosing question order, no need for secure secrets package. + state.qkd_question_order = question_order + elif state.leading and state.followers_address == "": + logger.error("Leader node has no follower address set") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Leader node has no follower address set", + ) + + elif state.following and state.leaders_address != "": + try: + r = await http_client.get(f"http://{state.leaders_address}/qkd/question_order") + if r.status_code != status.HTTP_200_OK: + logger.error("Failed to get question order from leader: %s", r.text) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get question order from leader", + ) + state.qkd_question_order = r.json() + except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as e: + logger.exception("Error requesting question order from leader: %s") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error requesting question order from leader: {e}", + ) from e + elif state.following and state.leaders_address == "": + logger.error("Follower node has no leader address set") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Follower node has no leader address set", + ) + + return state.qkd_question_order + + +@router.get("/is_follower_ready") +async def is_follower_ready(state: StateDep) -> bool: + """ + Check if the follower node is ready for QKD. + + Follower is ready when the state has the basis list with as many choices as the bitstring length. + """ + if not state.following: + logger.error("Node is not a follower") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Node is not a follower", + ) + + return len(state.qkd_follower_basis_list) == settings.qkd_settings.bitstring_length + + +@router.post("/submit_qkd_result") +async def submit_qkd_result(result: QKDResult, state: StateDep) -> None: + """QKD leader calls this endpoint of the follower to submit the QKD result as well as the emoji chosen.""" + state.qkd_emoji_pick = result.emoji + state.qkd_n_matching_bits = result.n_matching_bits + qkd_result_received_event.set() # Signal that the result has been received + logger.info("Received QKD result from follower: %s", result) + + +async def _wait_for_follower_ready(state: NodeState, http_client: httpx.AsyncClient) -> None: + """Poll the follower until it's ready, checking every 0.5 seconds.""" + while True: + try: + r = await http_client.get(f"http://{state.followers_address}/qkd/is_follower_ready") + if r.status_code == status.HTTP_200_OK: + is_ready = r.json() + if is_ready: + logger.info("Follower has all basis choices. Ready to start QKD") + break + logger.info("Tried checking if follower is ready, but it wasn't ready") + else: + logger.info("Tried checking if follower is ready, but received non-200 status code") + except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as e: + logger.info("Tried checking if follower is ready, but encountered error: %s", e) + + await asyncio.sleep(0.5) + + +async def _submit_qkd_result_to_follower( + state: NodeState, http_client: httpx.AsyncClient, qkd_result: QKDResult +) -> None: + """Submit the QKD result to the follower node.""" + try: + r = await http_client.post( + f"http://{state.followers_address}/qkd/submit_qkd_result", json=qkd_result.model_dump() + ) + if r.status_code != status.HTTP_200_OK: + logger.error("Failed to submit QKD result to follower: %s", r.text) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to submit QKD result to follower", + ) + logger.info("Successfully submitted QKD result to follower") + except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as e: + logger.exception("Error submitting QKD result to follower") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error submitting QKD result to follower: {e}", + ) from e + + +async def _submit_qkd_basis_list_leader( + state: NodeState, http_client: httpx.AsyncClient, basis_list: list[QKDEncodingBasis], timetagger_address: str +) -> QKDResult: + state.qkd_leader_basis_list = basis_list + await _wait_for_follower_ready(state, http_client) + + ret = await _qkd(state.followers_address, http_client, state, timetagger_address) + logger.info("Final QKD bits: %s", str(ret)) + + # Assemble QKDResult object + qkd_result = QKDResult( + n_matching_bits=len(ret), + n_total_bits=settings.qkd_settings.bitstring_length, + emoji=state.qkd_emoji_pick, + role="leader", + ) + + # Submit result to follower + await _submit_qkd_result_to_follower(state, http_client, qkd_result) + return qkd_result + + +async def _submit_qkd_basis_list_follower(state: NodeState, basis_list: list[QKDEncodingBasis]) -> QKDResult: + state.qkd_follower_basis_list = basis_list + + # don't wait for the event if the result is already set. This avoids deadlocks in case the result was set before this function is called. + if state.qkd_n_matching_bits == -1: + # Wait until the leader submits the QKD result + await qkd_result_received_event.wait() + + # Reassemble the QKDResult object from the state + qkd_result = QKDResult( + n_matching_bits=state.qkd_n_matching_bits, + n_total_bits=settings.qkd_settings.bitstring_length, + emoji=state.qkd_emoji_pick, + role="follower", + ) + + # Clear the event for the next QKD run + qkd_result_received_event.clear() + + logger.info("Follower received QKD result: %s", state.qkd_n_matching_bits) + return qkd_result + + +@router.post("/submit_selection_and_start_qkd") +async def submit_qkd_selection_and_start_qkd( + state: StateDep, http_client: ClientDep, basis_list: list[str], timetagger_address: str = "" +) -> QKDResult: + """ + GUI calls this function to submit the QKD basis selection and start the QKD protocol. + + This call is called by both leader and follower, depending on the node role, different actions are taken. + """ + if state.leading and state.following: + logger.error("Node cannot be both leader and follower") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Node cannot be both leader and follower", + ) + + if not state.leading and not state.following: + logger.error("Node must be either leader or follower to start QKD") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Node must be either leader or follower to start QKD", + ) + + # Convert 'a' or 'b' strings to QKDEncodingBasis enum values + qkd_basis_list = [] + for basis_str in basis_list: + if basis_str.lower() == "a": + qkd_basis_list.append(QKDEncodingBasis.HV) + elif basis_str.lower() == "b": + qkd_basis_list.append(QKDEncodingBasis.DA) + else: + logger.exception("Invalid basis string: %s. Expected 'a' or 'b'", basis_str) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid basis string: {basis_str}. Expected 'a' or 'b'", + ) + + if state.leading: + if timetagger_address == "": + logger.error("Leader must provide timetagger address to start QKD") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Leader must provide timetagger address to start QKD", + ) + return await _submit_qkd_basis_list_leader(state, http_client, qkd_basis_list, timetagger_address) + + # If the node is not leading, it is assumed it is a follower due to previous check + return await _submit_qkd_basis_list_follower(state, qkd_basis_list) diff --git a/src/pqnstack/app/core/config.py b/src/pqnstack/app/core/config.py index 1999eac7..a6659792 100644 --- a/src/pqnstack/app/core/config.py +++ b/src/pqnstack/app/core/config.py @@ -1,3 +1,4 @@ +import asyncio import logging from functools import lru_cache @@ -21,17 +22,21 @@ class CHSHSettings(BaseModel): hwp: tuple[str, str] = ("", "") request_hwp: tuple[str, str] = ("", "") measurement_config: MeasurementConfig = Field(default_factory=lambda: MeasurementConfig(integration_time_s=5)) + expectation_signs: tuple[int, int, int, int] = (1, 1, 1, -1) class QKDSettings(BaseModel): hwp: tuple[str, str] = ("", "") request_hwp: tuple[str, str] = ("", "") - bitstring_length: int = 4 + bitstring_length: int = 6 + minimum_question_index: int = 1 + maximum_question_index: int = 8 discriminating_threshold: int = 10 measurement_config: MeasurementConfig = Field(default_factory=lambda: MeasurementConfig(integration_time_s=5)) class Settings(BaseSettings): + node_name: str = "node1" router_name: str = "router1" router_address: str = "localhost" router_port: int = 5555 @@ -73,9 +78,32 @@ def get_settings() -> Settings: class NodeState(BaseModel): + # Coordination state + # FIXME: Make sure we are checking for the client_listening_for_follower_requests state everywhere. + client_listening_for_follower_requests: bool = False + + # Leader's state + leading: bool = False + followers_address: str = "" + + # Follower's state + following: bool = False + # Other node requested this node to follow it. + following_requested: bool = False + # User's response to the follow request. None if no response yet, True if accepted, False if rejected. + following_requested_user_response: bool | None = None + # The address of the leader this node is following. None if not following anyone. + leaders_address: str = "" + leaders_name: str = "" + + # CHSH state chsh_request_basis: list[float] = [22.5, 67.5] - # FIXME: Use enums for this - qkd_basis_list: list[QKDEncodingBasis] = [ + + # QKD state + # FIXME: At the moment the reset_coordination_state resets this, probably want to refactor that function out. + qkd_question_order: list[int] = [] # Order of questions for QKD + qkd_emoji_pick: str = "" # Emoji chosen for QKD + qkd_leader_basis_list: list[QKDEncodingBasis] = [ QKDEncodingBasis.DA, QKDEncodingBasis.DA, QKDEncodingBasis.DA, @@ -88,10 +116,20 @@ class NodeState(BaseModel): QKDEncodingBasis.HV, QKDEncodingBasis.HV, ] + qkd_follower_basis_list: list[QKDEncodingBasis] = [] + qkd_single_bit_current_index: int = 0 # Current index in follower basis list for single_bit endpoint qkd_bit_list: list[int] = [] qkd_resulting_bit_list: list[int] = [] # Resulting bits after QKD qkd_request_basis_list: list[QKDEncodingBasis] = [] # Basis angles for QKD qkd_request_bit_list: list[int] = [] + qkd_n_matching_bits: int = -1 # Leaders populate this value after qkd is done. Same with the emoji state = NodeState() +ask_user_for_follow_event = asyncio.Event() +user_replied_event = asyncio.Event() +qkd_result_received_event = asyncio.Event() + + +def get_state() -> NodeState: + return state