Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ them, watch through their camera, teach routes and run their AI inspection, from
Pocket 4 or any Android device in landscape.**

[![CI](https://github.com/DarmawanRobotics/PashupatiController/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/DarmawanRobotics/PashupatiController/actions/workflows/ci.yml)
![Version](https://img.shields.io/badge/version-2.6.1-4FD1C5)
![Version](https://img.shields.io/badge/version-2.7.0-4FD1C5)
![Flutter](https://img.shields.io/badge/Flutter-3.35%2B-02569B?logo=flutter&logoColor=white)
![Android](https://img.shields.io/badge/Android-11%2B-3DDC84?logo=android&logoColor=white)
![Robot](https://img.shields.io/badge/robot-ROS%202%20Humble-22314E?logo=ros&logoColor=white)
Expand Down Expand Up @@ -66,6 +66,9 @@ and takes the video over WebRTC. There is no ROS on the handheld.
- Battery, link quality, protective field, health and the localization source (identity, tag or
pose).
- The robot's CPU, GPU and RAM use and its temperature, at a glance, for development.
- Trip meter under the glance panel: distance and moving time, speed now, average and top speed
since the last reset. The robot counts (also with no handheld connected) and keeps it across
restarts; hold the panel to reset it to zero. Needs Pashupati with `odometry/trip`.

**Everything else**
- Finds robots on the LAN by itself, and connects with a token.
Expand All @@ -79,7 +82,7 @@ and takes the video over WebRTC. There is no ROS on the handheld.
browser or file manager once), or from a computer:

```bash
adb install -r pashupati-controller-2.6.2.apk # -r: update in place, settings kept
adb install -r pashupati-controller-2.7.0.apk # -r: update in place, settings kept
```

Releases are signed with one release key, so a newer release installs over an older one.
Expand Down Expand Up @@ -171,7 +174,7 @@ lib/
├── net/ teleop_client.dart (session, commands, joystick), discovery.dart,
│ webrtc_video.dart + whep.dart (camera over WebRTC)
├── input/ gamepad_input.dart (gamepads, normalized)
├── widgets/ hud, camera (tag and person boxes), map, glance (AI, people, load)
├── widgets/ hud, camera (tag and person boxes), map, glance (AI, people, load), trip
├── screens/ connect, drive (home), patrol (inspection), routes, settings
├── robot_actions.dart icons and labels of known robot commands
└── l10n.dart Indonesian and English text
Expand All @@ -192,11 +195,11 @@ Bump `version` in `pubspec.yaml` (the name and the `+` build number), merge it t
that commit with the same version:

```bash
git tag v2.6.2 && git push origin v2.6.2
git tag v2.7.0 && git push origin v2.7.0
```

The `release` workflow tests, builds and publishes `pashupati-controller-2.6.2.apk` (and its
SHA-256) as the GitHub Release `v2.6.2`. A tag that differs from `pubspec.yaml` fails the run.
The `release` workflow tests, builds and publishes `pashupati-controller-2.7.0.apk` (and its
SHA-256) as the GitHub Release `v2.7.0`. A tag that differs from `pubspec.yaml` fails the run.

The release key lives in four repository secrets (Settings → Secrets and variables → Actions):
`ANDROID_KEYSTORE_BASE64` (the keystore, `base64 -w0 release.jks`), `ANDROID_KEYSTORE_PASSWORD`,
Expand Down
12 changes: 12 additions & 0 deletions lib/l10n.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ const _en = <String, String>{
'speed_unavailable': 'Navigation is not running (teach mode, or starting).',
'speed_unsupported': 'This robot cannot set its patrol speed yet: update Pashupati.',
'glance_waiting': 'AI: waiting for a result',
'trip_distance': '{d} · moving {t}',
'trip_speed': 'Now {v} m/s',
'trip_average': 'Avg {a} · top {m} m/s',
'trip_hold': 'Hold to reset to zero',
'trip_reset': 'Trip reset to zero',
'ai_loading': 'AI: loading the model…',
'ai_off': 'AI off',
'ai_error': 'AI: error',
Expand All @@ -196,6 +201,7 @@ const _en = <String, String>{
'people_missing': 'No person detection here: PEOPLE=true in pashupati.env, then restart.',
'people_detail': '{n} in view · {ms} ms · {backend}',
'show_load': 'Robot CPU, GPU and RAM on screen',
'show_trip': 'Distance and speed on screen',
// routes
'routes': 'Routes',
'refresh': 'Refresh',
Expand Down Expand Up @@ -400,6 +406,11 @@ const _id = <String, String>{
'speed_unavailable': 'Navigasi tidak jalan (mode teach, atau sedang mulai).',
'speed_unsupported': 'Robot ini belum bisa atur kecepatan patroli: update Pashupati.',
'glance_waiting': 'AI: menunggu hasil',
'trip_distance': '{d} · jalan {t}',
'trip_speed': 'Sekarang {v} m/s',
'trip_average': 'Rata² {a} · maks {m} m/s',
'trip_hold': 'Tahan untuk nol-kan',
'trip_reset': 'Trip di-nol-kan',
'ai_loading': 'AI: memuat model…',
'ai_off': 'AI mati',
'ai_error': 'AI: error',
Expand All @@ -417,6 +428,7 @@ const _id = <String, String>{
'people_missing': 'Deteksi orang tidak jalan: set PEOPLE=true di pashupati.env lalu restart.',
'people_detail': '{n} terlihat · {ms} ms · {backend}',
'show_load': 'CPU, GPU dan RAM robot di layar',
'show_trip': 'Jarak dan kecepatan di layar',
'routes': 'Rute',
'refresh': 'Muat ulang',
'no_routes': 'Belum ada rute. Rekam satu dulu.',
Expand Down
66 changes: 66 additions & 0 deletions lib/protocol/messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const pashupatiCommands = {
'inspection_categories',
'people_set',
'nav_speed',
'trip_reset',
};

double? _num(Object? value) => value is num && value.isFinite ? value.toDouble() : null;
Expand Down Expand Up @@ -191,6 +192,7 @@ class RobotState {
this.people,
this.load,
this.speed,
this.trip,
});

factory RobotState.fromJson(Map<String, dynamic> json) {
Expand Down Expand Up @@ -232,6 +234,7 @@ class RobotState {
people: PeopleStatus.fromJson(json['people']),
load: SystemLoad.fromJson(health),
speed: PatrolSpeed.fromJson(json['speed']),
trip: TripStatus.fromJson(json['trip']),
);
}

Expand Down Expand Up @@ -273,6 +276,10 @@ class RobotState {
/// Patrol speed of the navigation; null when navigation does not run.
final PatrolSpeed? speed;

/// Distance and speeds since the last trip_reset, counted on the robot; null on a robot
/// without the trip meter.
final TripStatus? trip;

bool get remote => mode == 'remote';
bool get localized => localization.isNotEmpty && localization != 'none';

Expand Down Expand Up @@ -465,6 +472,65 @@ class PatrolSpeed {
final double max;
}

/// `state.trip`: distance and speeds since the last `trip_reset`, counted on the robot from its
/// LiDAR odometry (dharma_odometry), also while no handheld is connected.
class TripStatus {
const TripStatus({
required this.distance,
required this.movingSeconds,
required this.speed,
required this.average,
required this.top,
this.since,
});

/// Null unless the distance and the speeds are numbers.
static TripStatus? fromJson(Object? json) {
if (json is! Map) return null;
final distance = _num(json['m']);
final speed = _num(json['v']);
if (distance == null || speed == null) return null;
final since = _num(json['since']);
return TripStatus(
distance: distance,
movingSeconds: _num(json['s']) ?? 0,
speed: speed,
average: _num(json['avg']) ?? 0,
top: _num(json['max']) ?? 0,
since: since != null && since > 0
? DateTime.fromMillisecondsSinceEpoch((since * 1000).round())
: null,
);
}

/// m travelled.
final double distance;

/// s spent moving.
final double movingSeconds;

/// m/s now, average while moving, and the highest.
final double speed;
final double average;
final double top;

/// When the trip was last reset.
final DateTime? since;
}

/// "12.3 m" below a kilometre, "1.24 km" above.
String formatDistance(double metres) =>
metres < 1000 ? '${metres.toStringAsFixed(1)} m' : '${(metres / 1000).toStringAsFixed(2)} km';

/// "m:ss" below an hour, "h:mm:ss" above.
String formatDuration(double seconds) {
final s = seconds.floor();
final h = s ~/ 3600;
final m = (s % 3600) ~/ 60;
final rest = (s % 60).toString().padLeft(2, '0');
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$rest' : '$m:$rest';
}

/// `state.people`: the robot's person detection (person_detector_node).
class PeopleStatus {
const PeopleStatus({
Expand Down
2 changes: 1 addition & 1 deletion lib/screens/connect_screen.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'dart:math' as math;

import 'package:flutter/material.dart';

Check notice on line 3 in lib/screens/connect_screen.dart

View workflow job for this annotation

GitHub Actions / analyze-test

DEPRECATED_MEMBER_USE

'package:flutter/material.dart' is deprecated and shouldn't be used. Use package:material_ui/material_ui.dart instead. This feature was deprecated after v3.47.0-0.0.pre.

Check notice on line 3 in lib/screens/connect_screen.dart

View workflow job for this annotation

GitHub Actions / analyze-test

DEPRECATED_MEMBER_USE

'package:flutter/material.dart' is deprecated and shouldn't be used. Use package:material_ui/material_ui.dart instead. This feature was deprecated after v3.47.0-0.0.pre.

import '../net/teleop_client.dart';
import '../protocol/messages.dart';
Expand All @@ -10,7 +10,7 @@
import 'drive_screen.dart';
import 'settings_screen.dart';

const appVersion = '2.6.1'; // the version in pubspec.yaml (test/version_test.dart)
const appVersion = '2.7.0'; // the version in pubspec.yaml (test/version_test.dart)

/// Robot picker: the main robot from the settings plus every robot that answers on the LAN.
class ConnectScreen extends StatefulWidget {
Expand Down
33 changes: 27 additions & 6 deletions lib/screens/drive_screen.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:math' as math;

import 'package:flutter/material.dart';

Check notice on line 4 in lib/screens/drive_screen.dart

View workflow job for this annotation

GitHub Actions / analyze-test

DEPRECATED_MEMBER_USE

'package:flutter/material.dart' is deprecated and shouldn't be used. Use package:material_ui/material_ui.dart instead. This feature was deprecated after v3.47.0-0.0.pre.

Check notice on line 4 in lib/screens/drive_screen.dart

View workflow job for this annotation

GitHub Actions / analyze-test

DEPRECATED_MEMBER_USE

'package:flutter/material.dart' is deprecated and shouldn't be used. Use package:material_ui/material_ui.dart instead. This feature was deprecated after v3.47.0-0.0.pre.
import 'package:flutter/services.dart';
import 'package:gamepads/gamepads.dart';

Expand All @@ -15,6 +15,7 @@
import '../widgets/camera_view.dart';
import '../widgets/glance.dart';
import '../widgets/hud.dart';
import '../widgets/trip_panel.dart';
import '../widgets/map_view.dart';
import 'patrol_screen.dart';
import 'routes_screen.dart';
Expand Down Expand Up @@ -228,6 +229,12 @@
return result;
}

/// Holding the trip panel: the robot starts its trip meter again from zero.
void _resetTrip() {
HapticFeedback.mediumImpact();
_run('trip_reset', label: s.t('trip_reset'));
}

void _estop() {
HapticFeedback.heavyImpact();
_show(Toast(s.t('estop_sent'), color: Palette.stop, icon: Icons.pan_tool_rounded));
Expand Down Expand Up @@ -515,7 +522,7 @@
// the map listens to the viewport itself: a pinch does not rebuild the whole screen
child: ListenableBuilder(listenable: _viewport, builder: (context, _) => _viewButtons()),
),
// last inspection, people in view and the robot's load, above the toolbar
// last inspection, people in view, the robot's load and its trip, above the toolbar
if (size.height - (top + insetHeight + 52) - 70 > 30)
Positioned(
key: const ValueKey('glance'),
Expand All @@ -527,11 +534,25 @@
alignment: Alignment.topLeft,
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: GlancePanel(
state: state,
language: s.settings.language,
showLoad: s.settings.showLoad,
onTap: (client.welcome?.has('inspection_set') ?? false) ? _openPatrol : null,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GlancePanel(
state: state,
language: s.settings.language,
showLoad: s.settings.showLoad,
onTap: (client.welcome?.has('inspection_set') ?? false) ? _openPatrol : null,
),
if (s.settings.showTrip && state.trip != null) ...[
const SizedBox(height: 6),
TripPanel(
trip: state.trip!,
language: s.settings.language,
onReset: (client.welcome?.has('trip_reset') ?? false) ? _resetTrip : null,
),
],
],
),
),
),
Expand Down
1 change: 1 addition & 0 deletions lib/screens/settings_screen.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';

Check notice on line 1 in lib/screens/settings_screen.dart

View workflow job for this annotation

GitHub Actions / analyze-test

DEPRECATED_MEMBER_USE

'package:flutter/material.dart' is deprecated and shouldn't be used. Use package:material_ui/material_ui.dart instead. This feature was deprecated after v3.47.0-0.0.pre.

Check notice on line 1 in lib/screens/settings_screen.dart

View workflow job for this annotation

GitHub Actions / analyze-test

DEPRECATED_MEMBER_USE

'package:flutter/material.dart' is deprecated and shouldn't be used. Use package:material_ui/material_ui.dart instead. This feature was deprecated after v3.47.0-0.0.pre.
import 'package:gamepads/gamepads.dart';

import '../net/webrtc_video.dart';
Expand Down Expand Up @@ -154,6 +154,7 @@
),
_switch(t('lidar_on_map'), _settings.lidar, (value) => _settings.update(lidar: value)),
_switch(t('show_load'), _settings.showLoad, (value) => _settings.update(showLoad: value)),
_switch(t('show_trip'), _settings.showTrip, (value) => _settings.update(showTrip: value)),
_choice(
t('language'),
SegmentedButton<String>(
Expand Down
5 changes: 5 additions & 0 deletions lib/settings.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

Expand Down Expand Up @@ -49,6 +49,9 @@
/// The robot computer's CPU, GPU and memory use on the drive screen (for development).
bool get showLoad => _prefs.getBool('show_load') ?? true;

/// The robot's trip meter (distance, speeds) on the drive screen.
bool get showTrip => _prefs.getBool('show_trip') ?? true;

/// Inspection time of a stop point marked from the handheld, seconds.
double get stopDwell => _prefs.getDouble('dwell') ?? 10;

Expand All @@ -69,6 +72,7 @@
String? mainView,
bool? deadman,
bool? showLoad,
bool? showTrip,
double? stopDwell,
int? gear,
}) async {
Expand All @@ -84,6 +88,7 @@
if (mainView != null) _prefs.setString('main_view', mainView),
if (deadman != null) _prefs.setBool('deadman', deadman),
if (showLoad != null) _prefs.setBool('show_load', showLoad),
if (showTrip != null) _prefs.setBool('show_trip', showTrip),
if (stopDwell != null) _prefs.setDouble('dwell', stopDwell),
if (gear != null) _prefs.setInt('gear', _gear(gear)),
];
Expand Down
93 changes: 93 additions & 0 deletions lib/widgets/trip_panel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';

import '../l10n.dart';
import '../protocol/messages.dart';
import '../theme.dart';
import 'hud.dart';

/// The robot's trip meter under the glance panel: distance and moving time, the speed now, the
/// average and the top speed since the last reset. The robot counts (also while no handheld is
/// connected); holding the panel asks it to start again from zero.
class TripPanel extends StatelessWidget {
const TripPanel({super.key, required this.trip, required this.language, this.onReset});

final TripStatus trip;
final String language;

/// Hold to reset; null on a robot that cannot (no trip_reset command).
final VoidCallback? onReset;

String t(String key, [Map<String, Object> args = const {}]) => tr(language, key, args);

@override
Widget build(BuildContext context) {
return GestureDetector(
key: const ValueKey('trip'),
behavior: HitTestBehavior.opaque,
onLongPress: onReset,
child: Glass(
radius: 12,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_row(
Icons.route_rounded,
Palette.text,
t('trip_distance', {
'd': formatDistance(trip.distance),
't': formatDuration(trip.movingSeconds),
}),
strong: true,
),
_row(
Icons.speed_rounded,
trip.speed > 0 ? Palette.auto : Palette.muted,
t('trip_speed', {'v': trip.speed.toStringAsFixed(2)}),
),
_row(
Icons.show_chart_rounded,
Palette.muted,
t('trip_average', {
'a': trip.average.toStringAsFixed(2),
'm': trip.top.toStringAsFixed(2),
}),
),
if (onReset != null)
Padding(
padding: const EdgeInsets.only(top: 1),
child: Text(
t('trip_hold'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Txt.small.copyWith(color: Palette.muted, fontSize: 10),
),
),
],
),
),
);
}

Widget _row(IconData icon, Color color, String text, {bool strong = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 5),
Flexible(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: strong ? Txt.label.copyWith(color: color) : Txt.small,
),
),
],
),
);
}
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: flutter_teleop
description: Handheld teleop for Pashupati robots (Retroid Pocket and other Android devices).
publish_to: none
version: 2.6.1+29
version: 2.7.0+30

environment:
sdk: ^3.9.0
Expand Down
Loading
Loading