mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-06 21:42:52 +02:00
Use numerical acks
This commit is contained in:
@@ -258,6 +258,11 @@ KeyStore.clear_private_key()
|
||||
|
||||
## ACK and Repeat Detection
|
||||
|
||||
The `acked` field is an integer count, not a boolean:
|
||||
- `0` = not acked
|
||||
- `1` = one ACK/echo received
|
||||
- `2+` = multiple flood echoes received
|
||||
|
||||
### Direct Message ACKs
|
||||
|
||||
When sending a direct message, an expected ACK code is tracked:
|
||||
@@ -267,7 +272,7 @@ from app.event_handlers import track_pending_ack
|
||||
track_pending_ack(expected_ack="abc123", message_id=42, timeout_ms=30000)
|
||||
```
|
||||
|
||||
When ACK event arrives, the message is marked as acked.
|
||||
When ACK event arrives, the message's ack count is incremented.
|
||||
|
||||
### Channel Message Repeats
|
||||
|
||||
@@ -276,7 +281,10 @@ Flood messages echo back through repeaters. Detection uses:
|
||||
- Text hash
|
||||
- Timestamp (±5 second window)
|
||||
|
||||
When a repeat is detected, the original outgoing message is marked as "acked".
|
||||
Each repeat increments the ack count. The frontend displays:
|
||||
- `?` = no acks
|
||||
- `✓` = 1 echo
|
||||
- `✓2`, `✓3`, etc. = multiple echoes (real-time updates via WebSocket)
|
||||
|
||||
### Auto-Contact Sync to Radio
|
||||
|
||||
|
||||
@@ -180,8 +180,8 @@ async def on_ack(event: "Event") -> None:
|
||||
message_id, _, _ = _pending_acks.pop(ack_code)
|
||||
logger.info("ACK received for message %d", message_id)
|
||||
|
||||
await MessageRepository.mark_acked(message_id)
|
||||
broadcast_event("message_acked", {"message_id": message_id})
|
||||
ack_count = await MessageRepository.increment_ack_count(message_id)
|
||||
broadcast_event("message_acked", {"message_id": message_id, "ack_count": ack_count})
|
||||
else:
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class Message(BaseModel):
|
||||
txt_type: int = 0
|
||||
signature: str | None = None
|
||||
outgoing: bool = False
|
||||
acked: bool = False
|
||||
acked: int = 0
|
||||
|
||||
|
||||
class RawPacket(BaseModel):
|
||||
|
||||
@@ -230,8 +230,8 @@ async def _process_group_text(
|
||||
# Don't pop - let it expire naturally so subsequent repeats via
|
||||
# different radio paths are also caught as duplicates
|
||||
logger.info("Repeat detected for channel message %d", message_id)
|
||||
await MessageRepository.mark_acked(message_id)
|
||||
broadcast_event("message_acked", {"message_id": message_id})
|
||||
ack_count = await MessageRepository.increment_ack_count(message_id)
|
||||
broadcast_event("message_acked", {"message_id": message_id, "ack_count": ack_count})
|
||||
is_repeat = True
|
||||
break
|
||||
|
||||
|
||||
@@ -313,17 +313,23 @@ class MessageRepository:
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=bool(row["acked"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def mark_acked(message_id: int) -> None:
|
||||
async def increment_ack_count(message_id: int) -> int:
|
||||
"""Increment ack count and return the new value."""
|
||||
await db.conn.execute(
|
||||
"UPDATE messages SET acked = 1 WHERE id = ?", (message_id,)
|
||||
"UPDATE messages SET acked = acked + 1 WHERE id = ?", (message_id,)
|
||||
)
|
||||
await db.conn.commit()
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT acked FROM messages WHERE id = ?", (message_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["acked"] if row else 1
|
||||
|
||||
@staticmethod
|
||||
async def find_duplicate(
|
||||
@@ -398,7 +404,7 @@ class MessageRepository:
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=bool(row["acked"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -88,7 +88,7 @@ The `useWebSocket` hook manages real-time connection:
|
||||
const wsHandlers = useMemo(() => ({
|
||||
onHealth: (data: HealthStatus) => setHealth(data),
|
||||
onMessage: (msg: Message) => { /* add to list, track unread */ },
|
||||
onMessageAcked: (messageId: number) => { /* update acked status */ },
|
||||
onMessageAcked: (messageId: number, ackCount: number) => { /* update ack count */ },
|
||||
// ...
|
||||
}), []);
|
||||
|
||||
@@ -199,7 +199,7 @@ interface Message {
|
||||
conversation_key: string; // PublicKey for PRIV, ChannelKey for CHAN
|
||||
text: string;
|
||||
outgoing: boolean;
|
||||
acked: boolean;
|
||||
acked: number; // 0=not acked, 1+=ack count (flood echoes)
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<script type="module" crossorigin src="/assets/index-Dj98vxU3.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BtEbese6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CtV9BARe.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -305,13 +305,13 @@ export function App() {
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
onMessageAcked: (messageId: number) => {
|
||||
// Update message acked status
|
||||
onMessageAcked: (messageId: number, ackCount: number) => {
|
||||
// Update message acked count
|
||||
setMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === messageId);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...prev[idx], acked: true };
|
||||
updated[idx] = { ...prev[idx], acked: ackCount };
|
||||
return updated;
|
||||
}
|
||||
return prev;
|
||||
@@ -639,7 +639,7 @@ export function App() {
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false, // Show as incoming (from the repeater)
|
||||
acked: true, // Mark as acked since it's a response
|
||||
acked: 1, // Mark as acked since it's a response
|
||||
};
|
||||
|
||||
// Create a second message for neighbors
|
||||
@@ -654,7 +654,7 @@ export function App() {
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: true,
|
||||
acked: 1,
|
||||
};
|
||||
|
||||
// Create a third message for ACL
|
||||
@@ -669,7 +669,7 @@ export function App() {
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: true,
|
||||
acked: 1,
|
||||
};
|
||||
|
||||
// Add all messages to the list
|
||||
@@ -690,7 +690,7 @@ export function App() {
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: true,
|
||||
acked: 1,
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
}
|
||||
@@ -718,7 +718,7 @@ export function App() {
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: true,
|
||||
acked: 1,
|
||||
};
|
||||
setMessages((prev) => [...prev, commandMessage]);
|
||||
|
||||
@@ -740,7 +740,7 @@ export function App() {
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: true,
|
||||
acked: 1,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, responseMessage]);
|
||||
@@ -756,7 +756,7 @@ export function App() {
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: true,
|
||||
acked: 1,
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ export function MessageList({
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
)}
|
||||
{msg.outgoing && (msg.acked ? ' ✓' : ' ?')}
|
||||
{msg.outgoing && (msg.acked > 0 ? ` ✓${msg.acked > 1 ? msg.acked : ''}` : ' ?')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ function createMessage(overrides: Partial<Message>): Message {
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
acked: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('Repeater message sender parsing', () => {
|
||||
|
||||
it('non-repeater messages still get sender parsed', () => {
|
||||
const channelMessage = 'Alice: Hello everyone!';
|
||||
const contactType = CONTACT_TYPE_CLIENT;
|
||||
const contactType: number = CONTACT_TYPE_CLIENT;
|
||||
|
||||
const isRepeater = contactType === CONTACT_TYPE_REPEATER;
|
||||
const { sender, content } = isRepeater
|
||||
@@ -107,7 +107,7 @@ describe('Repeater password handling', () => {
|
||||
});
|
||||
|
||||
it('normal password is passed through unchanged', () => {
|
||||
const trimmed = 'mySecretPassword';
|
||||
const trimmed: string = 'mySecretPassword';
|
||||
const password = trimmed === '.' ? '' : trimmed;
|
||||
|
||||
expect(password).toBe('mySecretPassword');
|
||||
@@ -123,7 +123,7 @@ describe('Repeater password handling', () => {
|
||||
});
|
||||
|
||||
it('".." is NOT converted (only single dot)', () => {
|
||||
const trimmed = '..';
|
||||
const trimmed: string = '..';
|
||||
const password = trimmed === '.' ? '' : trimmed;
|
||||
|
||||
// Double dot is passed through as-is (it's a valid password)
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('shouldIncrementUnread', () => {
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
acked: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ function parseWebSocketMessage(
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
onMessageAcked?: (messageId: number) => void;
|
||||
onMessageAcked?: (messageId: number, ackCount: number) => void;
|
||||
}
|
||||
): { type: string; handled: boolean } {
|
||||
try {
|
||||
@@ -46,9 +46,11 @@ function parseWebSocketMessage(
|
||||
case 'raw_packet':
|
||||
handlers.onRawPacket?.(msg.data as RawPacket);
|
||||
return { type: msg.type, handled: !!handlers.onRawPacket };
|
||||
case 'message_acked':
|
||||
handlers.onMessageAcked?.((msg.data as { message_id: number }).message_id);
|
||||
case 'message_acked': {
|
||||
const ackData = msg.data as { message_id: number; ack_count: number };
|
||||
handlers.onMessageAcked?.(ackData.message_id, ackData.ack_count);
|
||||
return { type: msg.type, handled: !!handlers.onMessageAcked };
|
||||
}
|
||||
case 'pong':
|
||||
return { type: msg.type, handled: true };
|
||||
default:
|
||||
@@ -77,18 +79,18 @@ describe('parseWebSocketMessage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('routes message_acked to onMessageAcked with message ID', () => {
|
||||
it('routes message_acked to onMessageAcked with message ID and ack count', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
const data = JSON.stringify({
|
||||
type: 'message_acked',
|
||||
data: { message_id: 42 },
|
||||
data: { message_id: 42, ack_count: 3 },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, { onMessageAcked });
|
||||
|
||||
expect(result.type).toBe('message_acked');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42);
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42, 3);
|
||||
});
|
||||
|
||||
it('routes new message to onMessage handler', () => {
|
||||
@@ -100,7 +102,7 @@ describe('parseWebSocketMessage', () => {
|
||||
text: 'Hello',
|
||||
received_at: 1700000000,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
acked: 0,
|
||||
};
|
||||
const data = JSON.stringify({ type: 'message', data: messageData });
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ export interface Message {
|
||||
txt_type: number;
|
||||
signature: string | null;
|
||||
outgoing: boolean;
|
||||
acked: boolean;
|
||||
/** ACK count: 0 = not acked, 1+ = number of acks/flood echoes received */
|
||||
acked: number;
|
||||
}
|
||||
|
||||
export type ConversationType = 'contact' | 'channel' | 'raw';
|
||||
@@ -110,7 +111,8 @@ export interface AppSettingsUpdate {
|
||||
max_radio_contacts?: number;
|
||||
}
|
||||
|
||||
/** Contact type constant for repeaters */
|
||||
/** Contact type constants */
|
||||
export const CONTACT_TYPE_CLIENT = 1;
|
||||
export const CONTACT_TYPE_REPEATER = 2;
|
||||
|
||||
export interface NeighborInfo {
|
||||
|
||||
@@ -18,7 +18,7 @@ interface UseWebSocketOptions {
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
onMessageAcked?: (messageId: number) => void;
|
||||
onMessageAcked?: (messageId: number, ackCount: number) => void;
|
||||
onError?: (error: ErrorEvent) => void;
|
||||
}
|
||||
|
||||
@@ -82,9 +82,11 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
case 'raw_packet':
|
||||
options.onRawPacket?.(msg.data as RawPacket);
|
||||
break;
|
||||
case 'message_acked':
|
||||
options.onMessageAcked?.((msg.data as { message_id: number }).message_id);
|
||||
case 'message_acked': {
|
||||
const ackData = msg.data as { message_id: number; ack_count: number };
|
||||
options.onMessageAcked?.(ackData.message_id, ackData.ack_count);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
options.onError?.(msg.data as ErrorEvent);
|
||||
break;
|
||||
|
||||
@@ -143,7 +143,7 @@ class TestAckEventHandler:
|
||||
# Mock dependencies
|
||||
with patch("app.event_handlers.MessageRepository") as mock_repo, \
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
mock_repo.mark_acked = AsyncMock()
|
||||
mock_repo.increment_ack_count = AsyncMock(return_value=1)
|
||||
|
||||
# Create mock event
|
||||
class MockEvent:
|
||||
@@ -151,11 +151,11 @@ class TestAckEventHandler:
|
||||
|
||||
await on_ack(MockEvent())
|
||||
|
||||
# Verify message marked as acked
|
||||
mock_repo.mark_acked.assert_called_once_with(123)
|
||||
# Verify ack count incremented
|
||||
mock_repo.increment_ack_count.assert_called_once_with(123)
|
||||
|
||||
# Verify broadcast sent
|
||||
mock_broadcast.assert_called_once_with("message_acked", {"message_id": 123})
|
||||
# Verify broadcast sent with ack_count
|
||||
mock_broadcast.assert_called_once_with("message_acked", {"message_id": 123, "ack_count": 1})
|
||||
|
||||
# Verify pending ACK removed
|
||||
assert "deadbeef" not in _pending_acks
|
||||
@@ -169,13 +169,14 @@ class TestAckEventHandler:
|
||||
|
||||
with patch("app.event_handlers.MessageRepository") as mock_repo, \
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
mock_repo.increment_ack_count = AsyncMock()
|
||||
|
||||
class MockEvent:
|
||||
payload = {"code": "different"}
|
||||
|
||||
await on_ack(MockEvent())
|
||||
|
||||
mock_repo.mark_acked.assert_not_called()
|
||||
mock_repo.increment_ack_count.assert_not_called()
|
||||
mock_broadcast.assert_not_called()
|
||||
assert "expected" in _pending_acks
|
||||
|
||||
@@ -185,14 +186,14 @@ class TestAckEventHandler:
|
||||
from app.event_handlers import on_ack
|
||||
|
||||
with patch("app.event_handlers.MessageRepository") as mock_repo:
|
||||
mock_repo.mark_acked = AsyncMock()
|
||||
mock_repo.increment_ack_count = AsyncMock()
|
||||
|
||||
class MockEvent:
|
||||
payload = {"code": ""}
|
||||
|
||||
await on_ack(MockEvent())
|
||||
|
||||
mock_repo.mark_acked.assert_not_called()
|
||||
mock_repo.increment_ack_count.assert_not_called()
|
||||
|
||||
|
||||
class TestContactMessageCLIFiltering:
|
||||
|
||||
Reference in New Issue
Block a user