gunshot detection - direction optional as defined by profile

This commit is contained in:
stopflock
2025-08-29 13:25:25 -05:00
parent 57acff8ae7
commit 395ef77fe3
7 changed files with 124 additions and 9 deletions
+60 -2
View File
@@ -6,12 +6,14 @@ class CameraProfile {
final String name;
final Map<String, String> tags;
final bool builtin;
final bool requiresDirection;
CameraProfile({
required this.id,
required this.name,
required this.tags,
this.builtin = false,
this.requiresDirection = true,
});
/// Builtin default: Generic ALPR camera (view-only)
@@ -23,6 +25,7 @@ class CameraProfile {
'surveillance:type': 'ALPR',
},
builtin: true,
requiresDirection: true,
);
/// Builtin: Flock Safety ALPR camera
@@ -39,6 +42,7 @@ class CameraProfile {
'manufacturer:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: true,
);
/// Builtin: Motorola Solutions/Vigilant ALPR camera
@@ -55,6 +59,7 @@ class CameraProfile {
'manufacturer:wikidata': 'Q634815',
},
builtin: true,
requiresDirection: true,
);
/// Builtin: Genetec ALPR camera
@@ -71,6 +76,7 @@ class CameraProfile {
'manufacturer:wikidata': 'Q30295174',
},
builtin: true,
requiresDirection: true,
);
/// Builtin: Leonardo/ELSAG ALPR camera
@@ -87,6 +93,7 @@ class CameraProfile {
'manufacturer:wikidata': 'Q910379',
},
builtin: true,
requiresDirection: true,
);
/// Builtin: Neology ALPR camera
@@ -102,6 +109,49 @@ class CameraProfile {
'manufacturer': 'Neology, Inc.',
},
builtin: true,
requiresDirection: true,
);
/// Builtin: Generic gunshot detector
factory CameraProfile.genericGunshotDetector() => CameraProfile(
id: 'builtin-generic-gunshot',
name: 'Generic Gunshot Detector',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'gunshot_detector',
},
builtin: true,
requiresDirection: false,
);
/// Builtin: ShotSpotter gunshot detector
factory CameraProfile.shotspotter() => CameraProfile(
id: 'builtin-shotspotter',
name: 'ShotSpotter',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'surveillance:brand': 'ShotSpotter',
'surveillance:brand:wikidata': 'Q107740188',
},
builtin: true,
requiresDirection: false,
);
/// Builtin: Flock Raven gunshot detector
factory CameraProfile.flockRaven() => CameraProfile(
id: 'builtin-flock-raven',
name: 'Flock Raven',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'brand': 'Flock Safety',
'brand:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: false,
);
/// Returns true if this profile can be used for submissions
@@ -116,22 +166,30 @@ class CameraProfile {
String? name,
Map<String, String>? tags,
bool? builtin,
bool? requiresDirection,
}) =>
CameraProfile(
id: id ?? this.id,
name: name ?? this.name,
tags: tags ?? this.tags,
builtin: builtin ?? this.builtin,
requiresDirection: requiresDirection ?? this.requiresDirection,
);
Map<String, dynamic> toJson() =>
{'id': id, 'name': name, 'tags': tags, 'builtin': builtin};
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'tags': tags,
'builtin': builtin,
'requiresDirection': requiresDirection,
};
factory CameraProfile.fromJson(Map<String, dynamic> j) => CameraProfile(
id: j['id'],
name: j['name'],
tags: Map<String, String>.from(j['tags']),
builtin: j['builtin'] ?? false,
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
);
@override
+13 -1
View File
@@ -17,6 +17,7 @@ class ProfileEditor extends StatefulWidget {
class _ProfileEditorState extends State<ProfileEditor> {
late TextEditingController _nameCtrl;
late List<MapEntry<String, String>> _tags;
late bool _requiresDirection;
static const _defaultTags = [
MapEntry('man_made', 'surveillance'),
@@ -33,6 +34,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.profile.name);
_requiresDirection = widget.profile.requiresDirection;
if (widget.profile.tags.isEmpty) {
// New profile → start with sensible defaults
@@ -67,7 +69,16 @@ class _ProfileEditorState extends State<ProfileEditor> {
hintText: 'e.g., Custom ALPR Camera',
),
),
const SizedBox(height: 24),
const SizedBox(height: 16),
if (!widget.profile.builtin)
CheckboxListTile(
title: const Text('Requires Direction'),
subtitle: const Text('Whether cameras of this type need a direction tag'),
value: _requiresDirection,
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -170,6 +181,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
name: name,
tags: tagMap,
builtin: false,
requiresDirection: _requiresDirection,
);
context.read<AppState>().addOrUpdateProfile(newProfile);
+4 -2
View File
@@ -36,8 +36,10 @@ class Uploader {
print('Uploader: Created changeset ID: $csId');
// 2. create or update node
final mergedTags = Map<String, String>.from(p.profile.tags)
..['direction'] = p.direction.round().toString();
final mergedTags = Map<String, String>.from(p.profile.tags);
if (p.profile.requiresDirection) {
mergedTags['direction'] = p.direction.round().toString();
}
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
+3
View File
@@ -25,6 +25,9 @@ class ProfileState extends ChangeNotifier {
_profiles.add(CameraProfile.genetec());
_profiles.add(CameraProfile.leonardo());
_profiles.add(CameraProfile.neology());
_profiles.add(CameraProfile.genericGunshotDetector());
_profiles.add(CameraProfile.shotspotter());
_profiles.add(CameraProfile.flockRaven());
_profiles.addAll(await ProfileService().load());
// Load enabled profile IDs from prefs
+6 -2
View File
@@ -41,7 +41,9 @@ class UploadQueueState extends ChangeNotifier {
// Using timestamp as negative ID to ensure uniqueness
final tempId = -DateTime.now().millisecondsSinceEpoch;
final tags = Map<String, String>.from(upload.profile.tags);
tags['direction'] = upload.direction.toStringAsFixed(0);
if (upload.profile.requiresDirection) {
tags['direction'] = upload.direction.toStringAsFixed(0);
}
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
final tempNode = OsmCameraNode(
@@ -85,7 +87,9 @@ class UploadQueueState extends ChangeNotifier {
// 2. Create new temp node for the edited camera (purple ring) at new location
final tempId = -DateTime.now().millisecondsSinceEpoch;
final editedTags = Map<String, String>.from(upload.profile.tags);
editedTags['direction'] = upload.direction.toStringAsFixed(0);
if (upload.profile.requiresDirection) {
editedTags['direction'] = upload.direction.toStringAsFixed(0);
}
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
+19 -1
View File
@@ -64,9 +64,27 @@ class AddCameraSheet extends StatelessWidget {
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: (v) => appState.updateSession(directionDeg: v),
onChanged: session.profile.requiresDirection
? (v) => appState.updateSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
Icon(Icons.info_outline, color: Colors.grey, size: 16),
SizedBox(width: 6),
Expanded(
child: Text(
'This profile does not require a direction.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
+19 -1
View File
@@ -71,9 +71,27 @@ class EditCameraSheet extends StatelessWidget {
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: (v) => appState.updateEditSession(directionDeg: v),
onChanged: session.profile.requiresDirection
? (v) => appState.updateEditSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
Icon(Icons.info_outline, color: Colors.grey, size: 16),
SizedBox(width: 6),
Expanded(
child: Text(
'This profile does not require a direction.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
if (isSandboxMode)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),