@7365admin1/layer-common 3.2.1 → 3.2.2-staging.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/camera-wall-gated-endpoint.md +47 -0
- package/.changeset/camera-wall-plain-english.md +60 -0
- package/.changeset/camera-wall-selection-and-guidance.md +65 -0
- package/.changeset/dark-primary-contrast.md +39 -0
- package/.changeset/dashboard-dark-theme-contrast.md +15 -0
- package/.changeset/service-provider-invitation-actions.md +28 -0
- package/components/CameraWall.vue +1034 -0
- package/components/CameraWallTile.vue +818 -0
- package/components/DashboardMain.vue +84 -88
- package/components/DashboardPanel.vue +1 -1
- package/components/EntryPassInformation.vue +4 -4
- package/components/FeedbackMain.vue +7 -7
- package/components/HidAccessLogDashboard.vue +119 -44
- package/components/HidCameraCaptureDialog.vue +199 -0
- package/components/HidIntercomManagement.vue +808 -201
- package/components/HidQrCodeConfiguration.vue +907 -95
- package/components/HidServiceSettingsPanel.vue +20 -1
- package/components/HidUserEnrollment.vue +392 -81
- package/components/Layout/NavigationDrawer.vue +43 -2
- package/components/NavigationItem.vue +9 -0
- package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
- package/components/OnlineFormConfigurationForm.vue +620 -224
- package/components/OnlineFormFill.vue +36 -8
- package/components/OnlineFormsConfiguration.vue +6 -2
- package/components/QrTemplate/CreditCardLandscape.vue +19 -12
- package/components/QrTemplate/PrintDialog.vue +209 -196
- package/components/ServiceProviderInvitationPrompt.vue +150 -0
- package/components/ServiceProviderMain.vue +258 -14
- package/components/SiteSettings.vue +4 -2
- package/components/VisitorForm.vue +201 -78
- package/components/VisitorManagement.vue +2 -3
- package/components/VisitorSocketPopUp.vue +56 -2
- package/composables/useComment.ts +3 -3
- package/composables/useHidAmico.ts +127 -0
- package/composables/useHidNavigation.ts +0 -7
- package/composables/useLocalAuth.ts +9 -36
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useServiceProviderInvitation.ts +145 -0
- package/composables/useSipWebPhone.ts +311 -0
- package/composables/useSiteSettings.ts +50 -0
- package/composables/useVisitorSocket.ts +26 -0
- package/composables/useWebUsb.ts +127 -37
- package/package.json +3 -1
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/plugins/secure-member.client.ts +21 -56
- package/plugins/vuetify.ts +37 -9
- package/test/visitor-socket.test.mjs +36 -0
- package/types/site.d.ts +71 -0
- package/utils/camera-wall.test.ts +571 -0
- package/utils/camera-wall.ts +926 -0
- package/utils/theme.test.ts +216 -0
- package/utils/theme.ts +163 -0
- package/components/HidIdentityMapping.vue +0 -975
- package/middleware/member.ts +0 -4
- package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { computed, onScopeDispose, readonly, ref, shallowRef } from "vue";
|
|
2
|
+
import type { SimpleUser, SimpleUserDelegate, SimpleUserOptions } from "sip.js/lib/platform/web";
|
|
3
|
+
import type { SessionDescriptionHandlerOptions } from "sip.js/lib/platform/web/session-description-handler/session-description-handler-options";
|
|
4
|
+
|
|
5
|
+
export type SipWebPhoneConfig = {
|
|
6
|
+
webSocketServer: string;
|
|
7
|
+
aor: string;
|
|
8
|
+
authorizationUsername: string;
|
|
9
|
+
authorizationPassword: string;
|
|
10
|
+
displayName?: string;
|
|
11
|
+
video?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SipWebPhoneMedia = {
|
|
15
|
+
remoteAudio?: HTMLAudioElement | null;
|
|
16
|
+
remoteVideo?: HTMLVideoElement | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ConnectionState = "disconnected" | "connecting" | "registered" | "error";
|
|
20
|
+
type CallState = "idle" | "calling" | "incoming" | "active" | "ending";
|
|
21
|
+
|
|
22
|
+
export default function useSipWebPhone() {
|
|
23
|
+
const simpleUser = shallowRef<SimpleUser>();
|
|
24
|
+
const connectionState = ref<ConnectionState>("disconnected");
|
|
25
|
+
const callState = ref<CallState>("idle");
|
|
26
|
+
const errorMessage = ref("");
|
|
27
|
+
const muted = ref(false);
|
|
28
|
+
const remoteVideoAvailable = ref(false);
|
|
29
|
+
const currentTarget = ref("");
|
|
30
|
+
const activeConfig = shallowRef<SipWebPhoneConfig>();
|
|
31
|
+
|
|
32
|
+
const isRegistered = computed(() => connectionState.value === "registered");
|
|
33
|
+
const hasCall = computed(() => callState.value !== "idle");
|
|
34
|
+
|
|
35
|
+
async function connect(config: SipWebPhoneConfig, media: SipWebPhoneMedia) {
|
|
36
|
+
validateConfig(config);
|
|
37
|
+
await disconnect();
|
|
38
|
+
|
|
39
|
+
connectionState.value = "connecting";
|
|
40
|
+
errorMessage.value = "";
|
|
41
|
+
activeConfig.value = { ...config };
|
|
42
|
+
let clearRegistrationTimer = () => {};
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const { SimpleUser: SimpleUserConstructor } = await import("sip.js/lib/platform/web");
|
|
46
|
+
let registrationSettled = false;
|
|
47
|
+
let resolveRegistration: (() => void) | undefined;
|
|
48
|
+
let rejectRegistration: ((error: Error) => void) | undefined;
|
|
49
|
+
const registrationPromise = new Promise<void>((resolve, reject) => {
|
|
50
|
+
resolveRegistration = resolve;
|
|
51
|
+
rejectRegistration = reject;
|
|
52
|
+
});
|
|
53
|
+
const registrationTimeout = globalThis.setTimeout(() => {
|
|
54
|
+
if (registrationSettled) return;
|
|
55
|
+
registrationSettled = true;
|
|
56
|
+
rejectRegistration?.(new Error("SIP registration timed out. Check the WebSocket server and account credentials."));
|
|
57
|
+
}, 15000);
|
|
58
|
+
clearRegistrationTimer = () => globalThis.clearTimeout(registrationTimeout);
|
|
59
|
+
const settleRegistration = (error?: Error) => {
|
|
60
|
+
if (registrationSettled) return;
|
|
61
|
+
registrationSettled = true;
|
|
62
|
+
clearRegistrationTimer();
|
|
63
|
+
if (error) rejectRegistration?.(error);
|
|
64
|
+
else resolveRegistration?.();
|
|
65
|
+
};
|
|
66
|
+
const delegate: SimpleUserDelegate = {
|
|
67
|
+
onCallCreated: () => {
|
|
68
|
+
if (callState.value !== "incoming") callState.value = "calling";
|
|
69
|
+
},
|
|
70
|
+
onCallReceived: () => {
|
|
71
|
+
callState.value = "incoming";
|
|
72
|
+
currentTarget.value = "Incoming call";
|
|
73
|
+
},
|
|
74
|
+
onCallAnswered: () => {
|
|
75
|
+
callState.value = "active";
|
|
76
|
+
syncVideoState();
|
|
77
|
+
},
|
|
78
|
+
onCallHangup: () => {
|
|
79
|
+
const previousState = callState.value;
|
|
80
|
+
resetCall();
|
|
81
|
+
if (previousState === "calling") {
|
|
82
|
+
errorMessage.value = "The call was not answered or was rejected by the destination.";
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
onRegistered: () => {
|
|
86
|
+
connectionState.value = "registered";
|
|
87
|
+
settleRegistration();
|
|
88
|
+
},
|
|
89
|
+
onUnregistered: () => {
|
|
90
|
+
if (connectionState.value === "connecting") {
|
|
91
|
+
settleRegistration(new Error("SIP registration was rejected. Check the username and password."));
|
|
92
|
+
}
|
|
93
|
+
connectionState.value = "disconnected";
|
|
94
|
+
},
|
|
95
|
+
onServerDisconnect: () => {
|
|
96
|
+
const error = new Error("The SIP WebSocket server disconnected during registration.");
|
|
97
|
+
connectionState.value = "error";
|
|
98
|
+
errorMessage.value = error.message;
|
|
99
|
+
settleRegistration(error);
|
|
100
|
+
resetCall();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const options: SimpleUserOptions = {
|
|
104
|
+
aor: normalizeSipUri(config.aor),
|
|
105
|
+
delegate,
|
|
106
|
+
media: {
|
|
107
|
+
constraints: { audio: true, video: false },
|
|
108
|
+
remote: {
|
|
109
|
+
audio: media.remoteAudio || undefined,
|
|
110
|
+
video: media.remoteVideo || undefined,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
userAgentOptions: {
|
|
114
|
+
authorizationUsername: config.authorizationUsername,
|
|
115
|
+
authorizationPassword: config.authorizationPassword,
|
|
116
|
+
displayName: config.displayName || config.authorizationUsername,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const user = new SimpleUserConstructor(config.webSocketServer.trim(), options);
|
|
121
|
+
simpleUser.value = user;
|
|
122
|
+
await user.connect();
|
|
123
|
+
await user.register();
|
|
124
|
+
await registrationPromise;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
clearRegistrationTimer();
|
|
127
|
+
await disconnect(false);
|
|
128
|
+
connectionState.value = "error";
|
|
129
|
+
errorMessage.value = getErrorMessage(error);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function disconnect(clearError = true) {
|
|
135
|
+
const user = simpleUser.value;
|
|
136
|
+
simpleUser.value = undefined;
|
|
137
|
+
|
|
138
|
+
if (user) {
|
|
139
|
+
try {
|
|
140
|
+
if (hasCall.value) await user.hangup();
|
|
141
|
+
} catch {
|
|
142
|
+
// The session may already have ended remotely.
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await user.unregister();
|
|
146
|
+
} catch {
|
|
147
|
+
// Registration may not have completed.
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await user.disconnect();
|
|
151
|
+
} catch {
|
|
152
|
+
// The WebSocket may already be closed.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
connectionState.value = "disconnected";
|
|
157
|
+
activeConfig.value = undefined;
|
|
158
|
+
resetCall();
|
|
159
|
+
if (clearError) errorMessage.value = "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function call(target: string) {
|
|
163
|
+
const user = requireRegisteredUser();
|
|
164
|
+
const destination = normalizeDestination(target, activeConfig.value?.aor || "");
|
|
165
|
+
errorMessage.value = "";
|
|
166
|
+
currentTarget.value = destination;
|
|
167
|
+
callState.value = "calling";
|
|
168
|
+
try {
|
|
169
|
+
const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
|
|
170
|
+
constraints: { audio: true, video: false },
|
|
171
|
+
offerOptions: {
|
|
172
|
+
offerToReceiveAudio: true,
|
|
173
|
+
offerToReceiveVideo: Boolean(activeConfig.value?.video),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
await user.call(destination, { sessionDescriptionHandlerOptions });
|
|
177
|
+
} catch (error) {
|
|
178
|
+
resetCall();
|
|
179
|
+
errorMessage.value = getErrorMessage(error);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function answer() {
|
|
185
|
+
const user = requireRegisteredUser();
|
|
186
|
+
const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
|
|
187
|
+
constraints: { audio: true, video: false },
|
|
188
|
+
};
|
|
189
|
+
await user.answer({ sessionDescriptionHandlerOptions });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function decline() {
|
|
193
|
+
const user = requireUser();
|
|
194
|
+
callState.value = "ending";
|
|
195
|
+
await user.decline();
|
|
196
|
+
resetCall();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function hangup() {
|
|
200
|
+
const user = requireUser();
|
|
201
|
+
callState.value = "ending";
|
|
202
|
+
await user.hangup();
|
|
203
|
+
resetCall();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function toggleMute() {
|
|
207
|
+
const user = requireUser();
|
|
208
|
+
const tracks = user.localMediaStream?.getAudioTracks() || [];
|
|
209
|
+
if (!tracks.length) throw new Error("No microphone track is available for this call.");
|
|
210
|
+
const enable = muted.value;
|
|
211
|
+
tracks.forEach((track) => {
|
|
212
|
+
track.enabled = enable;
|
|
213
|
+
});
|
|
214
|
+
muted.value = !enable;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function syncVideoState() {
|
|
218
|
+
const user = simpleUser.value;
|
|
219
|
+
const remoteStream = user?.remoteMediaStream;
|
|
220
|
+
const remoteTrack = remoteStream?.getVideoTracks()[0];
|
|
221
|
+
|
|
222
|
+
const updateState = () => {
|
|
223
|
+
const currentRemoteTrack = remoteStream?.getVideoTracks()[0];
|
|
224
|
+
remoteVideoAvailable.value = Boolean(currentRemoteTrack && currentRemoteTrack.readyState === "live");
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
updateState();
|
|
228
|
+
if (remoteStream) remoteStream.onaddtrack = updateState;
|
|
229
|
+
|
|
230
|
+
if (remoteTrack) {
|
|
231
|
+
remoteTrack.onended = () => {
|
|
232
|
+
remoteVideoAvailable.value = false;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function resetCall() {
|
|
238
|
+
callState.value = "idle";
|
|
239
|
+
currentTarget.value = "";
|
|
240
|
+
muted.value = false;
|
|
241
|
+
remoteVideoAvailable.value = false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function requireRegisteredUser() {
|
|
245
|
+
const user = requireUser();
|
|
246
|
+
if (!isRegistered.value) throw new Error("Connect the web phone before making a call.");
|
|
247
|
+
return user;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function requireUser() {
|
|
251
|
+
if (!simpleUser.value) throw new Error("Web phone is not connected.");
|
|
252
|
+
return simpleUser.value;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
onScopeDispose(() => {
|
|
256
|
+
void disconnect();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
connectionState: readonly(connectionState),
|
|
261
|
+
callState: readonly(callState),
|
|
262
|
+
errorMessage: readonly(errorMessage),
|
|
263
|
+
muted: readonly(muted),
|
|
264
|
+
remoteVideoAvailable: readonly(remoteVideoAvailable),
|
|
265
|
+
currentTarget: readonly(currentTarget),
|
|
266
|
+
isRegistered,
|
|
267
|
+
hasCall,
|
|
268
|
+
connect,
|
|
269
|
+
disconnect,
|
|
270
|
+
call,
|
|
271
|
+
answer,
|
|
272
|
+
decline,
|
|
273
|
+
hangup,
|
|
274
|
+
toggleMute,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validateConfig(config: SipWebPhoneConfig) {
|
|
279
|
+
if (!/^wss:\/\//i.test(config.webSocketServer.trim())) {
|
|
280
|
+
throw new Error("SIP WebSocket URL must use WSS.");
|
|
281
|
+
}
|
|
282
|
+
if (!config.aor.trim() || !config.authorizationUsername.trim() || !config.authorizationPassword) {
|
|
283
|
+
throw new Error("SIP address, username, and password are required.");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeSipUri(value: string) {
|
|
288
|
+
const trimmed = value.trim();
|
|
289
|
+
return trimmed.toLowerCase().startsWith("sip:") ? trimmed : `sip:${trimmed}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function normalizeDestination(target: string, aor: string) {
|
|
293
|
+
const trimmed = target.trim();
|
|
294
|
+
if (!trimmed) throw new Error("Enter an extension or SIP address to call.");
|
|
295
|
+
if (trimmed.toLowerCase().startsWith("sip:")) return trimmed;
|
|
296
|
+
if (trimmed.includes("@")) return `sip:${trimmed}`;
|
|
297
|
+
|
|
298
|
+
const domain = normalizeSipUri(aor).split("@")[1];
|
|
299
|
+
if (!domain) throw new Error("Enter a full SIP address for the destination.");
|
|
300
|
+
return `sip:${trimmed}@${domain}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getErrorMessage(error: unknown) {
|
|
304
|
+
if (error instanceof DOMException && error.name === "NotAllowedError") {
|
|
305
|
+
return "Microphone permission was denied. Allow microphone access in the browser and reconnect the web phone.";
|
|
306
|
+
}
|
|
307
|
+
if (error instanceof DOMException && error.name === "NotFoundError") {
|
|
308
|
+
return "No microphone was found for this call.";
|
|
309
|
+
}
|
|
310
|
+
return error instanceof Error ? error.message : "SIP connection failed.";
|
|
311
|
+
}
|
|
@@ -99,6 +99,54 @@ export default function () {
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The monitoring wall for one site.
|
|
104
|
+
*
|
|
105
|
+
* A different endpoint from `getAllSiteCameras`, and the difference is the
|
|
106
|
+
* point. That one is the Settings panel's paginated CRUD list; this one is
|
|
107
|
+
* purpose-built for a wall and is what the React Native app already uses:
|
|
108
|
+
*
|
|
109
|
+
* - it is **site-scoped and authorised server-side** — the caller must be a
|
|
110
|
+
* member of the site, of its owning organisation, or work for an
|
|
111
|
+
* organisation engaged to serve it;
|
|
112
|
+
* - it returns `type: "ip"` cameras only, so an ANPR unit can never land on a
|
|
113
|
+
* monitoring wall;
|
|
114
|
+
* - it returns each camera's **capability descriptor** and the server's own
|
|
115
|
+
* `unavailableReason`, so a tile explains itself instead of showing a black
|
|
116
|
+
* rectangle;
|
|
117
|
+
* - it never returns a credential, and it contacts no device.
|
|
118
|
+
*
|
|
119
|
+
* Unpaginated by design: a wall shows a site's cameras, and paging them was
|
|
120
|
+
* an artefact of borrowing the CRUD list.
|
|
121
|
+
*/
|
|
122
|
+
async function getSiteWall(siteId: string) {
|
|
123
|
+
return await useNuxtApp().$api<Record<string, any>>(
|
|
124
|
+
`/api/site-cameras/site/${siteId}/wall`,
|
|
125
|
+
{ method: "GET" }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Health for the cameras currently on screen.
|
|
131
|
+
*
|
|
132
|
+
* Same site scoping as the wall, and the ids are narrowed to the visible
|
|
133
|
+
* tiles on purpose: this is the one camera call that can reach a device, and
|
|
134
|
+
* asking about cameras nobody is looking at spends a real budget for nothing.
|
|
135
|
+
*
|
|
136
|
+
* **Reachability is answered per RECORDER and cached there**, so a wall of
|
|
137
|
+
* twelve channels on one device is one connect rather than twelve, and
|
|
138
|
+
* several supervisors watching the same wall is still one. That is what makes
|
|
139
|
+
* it safe to ask on a timer at all — but only a slow one: the server caches
|
|
140
|
+
* for `healthCacheSeconds`, and asking faster than the cache buys nothing and
|
|
141
|
+
* costs round trips.
|
|
142
|
+
*/
|
|
143
|
+
async function getSiteHealth(siteId: string, cameraIds: string[]) {
|
|
144
|
+
return await useNuxtApp().$api<Record<string, any>>(
|
|
145
|
+
`/api/site-cameras/site/${siteId}/health`,
|
|
146
|
+
{ method: "GET", query: { ids: cameraIds.join(",") } }
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
102
150
|
async function updateSiteInformation(
|
|
103
151
|
siteId: string,
|
|
104
152
|
payload: { bgImage: string; description: string; docs: { id: string; name: string }[] }
|
|
@@ -175,6 +223,8 @@ export default function () {
|
|
|
175
223
|
updateSite,
|
|
176
224
|
addCamera,
|
|
177
225
|
getAllSiteCameras,
|
|
226
|
+
getSiteWall,
|
|
227
|
+
getSiteHealth,
|
|
178
228
|
setSiteGuardPosts,
|
|
179
229
|
updateSiteCamera,
|
|
180
230
|
deleteSiteCameraById,
|
|
@@ -5,6 +5,32 @@ export type TVisitorSocketData = {
|
|
|
5
5
|
message?: string;
|
|
6
6
|
messagePermanent?: string;
|
|
7
7
|
reload?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Camera health. Sent alongside `message` by API-core so the client can group
|
|
10
|
+
* or clear an alert per camera instead of matching on the message text.
|
|
11
|
+
* Absent on older API-core builds — treat a bare `message` as a fault.
|
|
12
|
+
*/
|
|
13
|
+
event?: "camera-fault" | "camera-recovered";
|
|
14
|
+
/** The camera the `event` is about. */
|
|
15
|
+
camera?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One line for however many cameras are currently down.
|
|
20
|
+
*
|
|
21
|
+
* A site with several ANPR cameras could otherwise raise one alert per camera
|
|
22
|
+
* at the same moment. The single-camera case shows the server's own message,
|
|
23
|
+
* which names the camera; beyond that, naming them all in a snackbar is worse
|
|
24
|
+
* than sending the operator to the page that lists them.
|
|
25
|
+
*/
|
|
26
|
+
export const cameraAlertMessage = (messages: string[]): string => {
|
|
27
|
+
if (!messages.length) return "";
|
|
28
|
+
if (messages.length === 1) return messages[0] as string;
|
|
29
|
+
return (
|
|
30
|
+
`${messages.length} cameras at this site are not responding. Plate reads ` +
|
|
31
|
+
`and automatic barrier opening are stopped for them. Open Site Settings > ` +
|
|
32
|
+
`Cameras to see which.`
|
|
33
|
+
);
|
|
8
34
|
};
|
|
9
35
|
|
|
10
36
|
export const useVisitorSocket = () => {
|
package/composables/useWebUsb.ts
CHANGED
|
@@ -11,6 +11,10 @@ interface USBDevice {
|
|
|
11
11
|
close(): Promise<void>;
|
|
12
12
|
selectConfiguration(configurationValue: number): Promise<void>;
|
|
13
13
|
claimInterface(interfaceNumber: number): Promise<void>;
|
|
14
|
+
selectAlternateInterface(
|
|
15
|
+
interfaceNumber: number,
|
|
16
|
+
alternateSetting: number,
|
|
17
|
+
): Promise<void>;
|
|
14
18
|
releaseInterface(interfaceNumber: number): Promise<void>;
|
|
15
19
|
transferOut(
|
|
16
20
|
endpointNumber: number,
|
|
@@ -19,14 +23,27 @@ interface USBDevice {
|
|
|
19
23
|
configuration: {
|
|
20
24
|
interfaces: Array<{
|
|
21
25
|
interfaceNumber: number;
|
|
26
|
+
alternate: USBAlternateInterface;
|
|
22
27
|
alternates: Array<{
|
|
28
|
+
alternateSetting: number;
|
|
29
|
+
interfaceClass: number;
|
|
23
30
|
endpoints: Array<{
|
|
24
31
|
endpointNumber: number;
|
|
25
32
|
direction: "in" | "out";
|
|
26
33
|
}>;
|
|
27
34
|
}>;
|
|
28
35
|
}>;
|
|
29
|
-
};
|
|
36
|
+
} | null;
|
|
37
|
+
configurations: Array<{ configurationValue: number }>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface USBAlternateInterface {
|
|
41
|
+
alternateSetting: number;
|
|
42
|
+
interfaceClass: number;
|
|
43
|
+
endpoints: Array<{
|
|
44
|
+
endpointNumber: number;
|
|
45
|
+
direction: "in" | "out";
|
|
46
|
+
}>;
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
interface USBOutTransferResult {
|
|
@@ -39,13 +56,86 @@ declare global {
|
|
|
39
56
|
usb: {
|
|
40
57
|
getDevices(): Promise<USBDevice[]>;
|
|
41
58
|
requestDevice(options: {
|
|
42
|
-
filters: Array<{
|
|
59
|
+
filters: Array<{
|
|
60
|
+
vendorId?: number;
|
|
61
|
+
productId?: number;
|
|
62
|
+
classCode?: number;
|
|
63
|
+
}>;
|
|
43
64
|
}): Promise<USBDevice>;
|
|
44
65
|
};
|
|
45
66
|
}
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
export default function useWebUsb() {
|
|
70
|
+
const printerConnections = new WeakMap<
|
|
71
|
+
USBDevice,
|
|
72
|
+
{ interfaceNumber: number; endpointNumber: number }
|
|
73
|
+
>();
|
|
74
|
+
|
|
75
|
+
const protectedInterfaceClasses = new Set([0x01, 0x03, 0x08, 0x0b, 0x0e, 0x10, 0xe0]);
|
|
76
|
+
|
|
77
|
+
function getErrorMessage(error: unknown, fallback: string) {
|
|
78
|
+
return error instanceof Error && error.message ? error.message : fallback;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function findPrinterInterface(device: USBDevice) {
|
|
82
|
+
const candidates = (device.configuration?.interfaces || []).flatMap((usbInterface) =>
|
|
83
|
+
usbInterface.alternates.flatMap((alternate) => {
|
|
84
|
+
const outputEndpoint = alternate.endpoints.find((endpoint) => endpoint.direction === "out");
|
|
85
|
+
if (!outputEndpoint || protectedInterfaceClasses.has(alternate.interfaceClass)) return [];
|
|
86
|
+
|
|
87
|
+
return [{
|
|
88
|
+
interfaceNumber: usbInterface.interfaceNumber,
|
|
89
|
+
alternateSetting: alternate.alternateSetting,
|
|
90
|
+
activeAlternateSetting: usbInterface.alternate.alternateSetting,
|
|
91
|
+
interfaceClass: alternate.interfaceClass,
|
|
92
|
+
endpointNumber: outputEndpoint.endpointNumber,
|
|
93
|
+
}];
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
candidates.sort((left, right) => {
|
|
98
|
+
const priority = (interfaceClass: number) => interfaceClass === 0x07 ? 0 : interfaceClass === 0xff ? 1 : 2;
|
|
99
|
+
return priority(left.interfaceClass) - priority(right.interfaceClass);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return candidates[0] || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function preparePrinterDevice(device: USBDevice) {
|
|
106
|
+
await device.open();
|
|
107
|
+
if (!device.configuration) {
|
|
108
|
+
await device.selectConfiguration(device.configurations[0]?.configurationValue || 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const printerInterface = findPrinterInterface(device);
|
|
112
|
+
if (!printerInterface) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"The selected USB device is not a compatible printer. Select the receipt printer instead of a Bluetooth, camera, keyboard, or other system device.",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await device.claimInterface(printerInterface.interfaceNumber);
|
|
119
|
+
if (printerInterface.alternateSetting !== printerInterface.activeAlternateSetting) {
|
|
120
|
+
await device.selectAlternateInterface(
|
|
121
|
+
printerInterface.interfaceNumber,
|
|
122
|
+
printerInterface.alternateSetting,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
printerConnections.set(device, {
|
|
127
|
+
interfaceNumber: printerInterface.interfaceNumber,
|
|
128
|
+
endpointNumber: printerInterface.endpointNumber,
|
|
129
|
+
});
|
|
130
|
+
return device;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getPrinterConnection(device: USBDevice) {
|
|
134
|
+
const connection = printerConnections.get(device);
|
|
135
|
+
if (!connection) throw new Error("Printer interface is not connected");
|
|
136
|
+
return connection;
|
|
137
|
+
}
|
|
138
|
+
|
|
49
139
|
const isWebUsbSupported = computed(() => {
|
|
50
140
|
return typeof navigator !== "undefined" && "usb" in navigator;
|
|
51
141
|
});
|
|
@@ -75,7 +165,9 @@ export default function useWebUsb() {
|
|
|
75
165
|
throw new Error("Web USB is not supported in this browser");
|
|
76
166
|
}
|
|
77
167
|
try {
|
|
78
|
-
const device = await navigator.usb.requestDevice({
|
|
168
|
+
const device = await navigator.usb.requestDevice({
|
|
169
|
+
filters: [{ classCode: 0x07 }, { classCode: 0xff }],
|
|
170
|
+
});
|
|
79
171
|
return {
|
|
80
172
|
vendorId: device.vendorId,
|
|
81
173
|
productId: device.productId,
|
|
@@ -84,12 +176,12 @@ export default function useWebUsb() {
|
|
|
84
176
|
serialNumber: device.serialNumber || "Unknown",
|
|
85
177
|
deviceId: `${device.vendorId}:${device.productId}`,
|
|
86
178
|
};
|
|
87
|
-
} catch (error:
|
|
88
|
-
if (error.name === "NotFoundError") {
|
|
179
|
+
} catch (error: unknown) {
|
|
180
|
+
if (error instanceof DOMException && error.name === "NotFoundError") {
|
|
89
181
|
throw new Error("No device selected");
|
|
90
182
|
}
|
|
91
183
|
console.error("Error requesting USB device:", error);
|
|
92
|
-
throw new Error("Failed to access USB
|
|
184
|
+
throw new Error(getErrorMessage(error, "Failed to access USB printer"));
|
|
93
185
|
}
|
|
94
186
|
};
|
|
95
187
|
|
|
@@ -110,14 +202,17 @@ export default function useWebUsb() {
|
|
|
110
202
|
});
|
|
111
203
|
}
|
|
112
204
|
console.log("[WebUSB] Opening device:", device.vendorId, device.productId);
|
|
113
|
-
await device
|
|
114
|
-
await device.selectConfiguration(1);
|
|
115
|
-
await device.claimInterface(0);
|
|
205
|
+
await preparePrinterDevice(device);
|
|
116
206
|
console.log("[WebUSB] Device opened and interface claimed.");
|
|
117
207
|
return device;
|
|
118
|
-
} catch (error) {
|
|
208
|
+
} catch (error: unknown) {
|
|
119
209
|
console.error("Error connecting to USB device:", error);
|
|
120
|
-
|
|
210
|
+
try {
|
|
211
|
+
await device?.close();
|
|
212
|
+
} catch (closeError) {
|
|
213
|
+
console.error("Error closing rejected USB device:", closeError);
|
|
214
|
+
}
|
|
215
|
+
throw new Error(getErrorMessage(error, "Failed to connect to USB printer"));
|
|
121
216
|
}
|
|
122
217
|
};
|
|
123
218
|
|
|
@@ -173,15 +268,20 @@ export default function useWebUsb() {
|
|
|
173
268
|
? "Connection and print test successful"
|
|
174
269
|
: "Connection successful but print test failed",
|
|
175
270
|
};
|
|
176
|
-
} catch (error:
|
|
271
|
+
} catch (error: unknown) {
|
|
177
272
|
return {
|
|
178
273
|
success: false,
|
|
179
|
-
error: error
|
|
274
|
+
error: getErrorMessage(error, "Failed to connect to USB printer"),
|
|
180
275
|
message: "Connection failed",
|
|
181
276
|
};
|
|
182
277
|
} finally {
|
|
183
278
|
if (device) {
|
|
184
279
|
try {
|
|
280
|
+
const connection = printerConnections.get(device);
|
|
281
|
+
if (connection) {
|
|
282
|
+
await device.releaseInterface(connection.interfaceNumber);
|
|
283
|
+
printerConnections.delete(device);
|
|
284
|
+
}
|
|
185
285
|
await device.close();
|
|
186
286
|
} catch (closeError) {
|
|
187
287
|
console.error("Error closing device:", closeError);
|
|
@@ -205,21 +305,16 @@ export default function useWebUsb() {
|
|
|
205
305
|
];
|
|
206
306
|
|
|
207
307
|
const testData = new Uint8Array(testCommands);
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
const outputEndpoint = alternate.endpoints.find(
|
|
211
|
-
(ep: any) => ep.direction === "out",
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
if (!outputEndpoint) {
|
|
215
|
-
throw new Error("No output endpoint found");
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
await device.transferOut(outputEndpoint.endpointNumber, testData);
|
|
308
|
+
const { endpointNumber } = getPrinterConnection(device);
|
|
309
|
+
await device.transferOut(endpointNumber, testData);
|
|
219
310
|
return { success: true, message: "Test print sent successfully" };
|
|
220
|
-
} catch (error:
|
|
311
|
+
} catch (error: unknown) {
|
|
221
312
|
console.error("Test print error:", error);
|
|
222
|
-
return {
|
|
313
|
+
return {
|
|
314
|
+
success: false,
|
|
315
|
+
error: getErrorMessage(error, "Test print failed"),
|
|
316
|
+
message: "Test print failed",
|
|
317
|
+
};
|
|
223
318
|
}
|
|
224
319
|
};
|
|
225
320
|
|
|
@@ -354,13 +449,8 @@ export default function useWebUsb() {
|
|
|
354
449
|
) => {
|
|
355
450
|
try {
|
|
356
451
|
console.log("[WebUSB] printQrCode start — doorLevel:", doorLevel, "liftLevel:", liftLevel, "company:", companyName, "address:", address);
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
const outputEndpoint = alternate.endpoints.find(
|
|
360
|
-
(ep: any) => ep.direction === "out",
|
|
361
|
-
);
|
|
362
|
-
if (!outputEndpoint) throw new Error("No output endpoint found");
|
|
363
|
-
console.log("[WebUSB] Using endpoint:", outputEndpoint.endpointNumber);
|
|
452
|
+
const { endpointNumber } = getPrinterConnection(device);
|
|
453
|
+
console.log("[WebUSB] Using endpoint:", endpointNumber);
|
|
364
454
|
|
|
365
455
|
const canvas = await createReceiptLayout(
|
|
366
456
|
urlImage,
|
|
@@ -375,17 +465,17 @@ export default function useWebUsb() {
|
|
|
375
465
|
const rasterData = canvasToRaster(canvas);
|
|
376
466
|
console.log("[WebUSB] Raster data size:", rasterData.byteLength, "bytes");
|
|
377
467
|
|
|
378
|
-
await device.transferOut(
|
|
468
|
+
await device.transferOut(endpointNumber, rasterData.buffer);
|
|
379
469
|
console.log("[WebUSB] Raster data sent.");
|
|
380
470
|
|
|
381
471
|
const CUT = new Uint8Array([0x1d, 0x56, 0x41, 0x10]);
|
|
382
|
-
await device.transferOut(
|
|
472
|
+
await device.transferOut(endpointNumber, CUT.buffer);
|
|
383
473
|
console.log("[WebUSB] Cut command sent.");
|
|
384
474
|
|
|
385
475
|
return { success: true, message: "QR print sent successfully" };
|
|
386
|
-
} catch (error:
|
|
476
|
+
} catch (error: unknown) {
|
|
387
477
|
console.error("[WebUSB] printQrCode error:", error);
|
|
388
|
-
return { success: false, error: error
|
|
478
|
+
return { success: false, error: getErrorMessage(error, "Unable to print QR code") };
|
|
389
479
|
}
|
|
390
480
|
};
|
|
391
481
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.2.
|
|
5
|
+
"version": "3.2.2-staging.76",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"publishConfig": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"build": "nuxt build .playground",
|
|
15
15
|
"generate": "nuxt generate .playground",
|
|
16
16
|
"preview": "nuxt preview .playground",
|
|
17
|
+
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && node --test \"test/*.test.mjs\"",
|
|
17
18
|
"release": "yarn run build && changeset publish"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
"qrcode": "^1.5.4",
|
|
41
42
|
"qrcode.vue": "^3.4.1",
|
|
42
43
|
"sass": "^1.80.6",
|
|
44
|
+
"sip.js": "0.21.2",
|
|
43
45
|
"socket.io-client": "^4.8.3",
|
|
44
46
|
"vue-draggable-next": "^2.3.0",
|
|
45
47
|
"vue3-signature": "^0.2.4",
|